Quiz entry hero

The finder's first question lives in the hero as a card radio group; the start button carries the chosen goal into the quiz.

blockquizsupplementswellnessbeautyctaquiz
$npx shadcn@latest add @storekit/hero-quiz
hero-quiz

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/hero-quiz
$pnpm dlx shadcn@latest add @storekit/hero-quiz
$yarn dlx shadcn@latest add @storekit/hero-quiz
$bunx --bun shadcn@latest add @storekit/hero-quiz

Source

components/storekit/hero/hero-quiz.tsx
167 lines
"use client"

import { useId, useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { ArrowRight, Check, Clock } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Quiz entry hero. The first question of the finder lives in the hero, so
   the first tap is an answer rather than a "Take the quiz" click. Picking a
   goal enables the start button, which carries the answer forward as
   `?goal=` on `href` (or hands it to `onStart`). The options are a native
   radio group styled as cards, so Arrow keys and screen readers work for
   free. Social proof and the time cost sit under the button. A product
   stack fills the visual side and stacks below on mobile. */

const EASE = [0.23, 1, 0.32, 1] as const

export type QuizOption = { value: string; label: string; hint?: string }

const SAMPLE: QuizOption[] = [
  { value: "energy", label: "Steady energy", hint: "No 3pm crash" },
  { value: "sleep", label: "Better sleep", hint: "Fall asleep faster" },
  { value: "focus", label: "Focus", hint: "Clearer workdays" },
  { value: "gut", label: "Gut health", hint: "Less bloat" },
  { value: "hydration", label: "Hydration", hint: "Training and heat" },
  { value: "recovery", label: "Recovery", hint: "After workouts" },
]

export function HeroQuiz({
  eyebrow = "Find your routine",
  headline = "Not sure where to start? Start here.",
  question = "What do you want help with first?",
  options = SAMPLE,
  startLabel = "Start the 2 minute quiz",
  href = "#",
  onStart,
  meta = "6 questions, about 2 minutes",
  proof = "Over 38,000 routines built",
  images = [],
  className,
}: {
  eyebrow?: string
  headline?: string
  question?: string
  options?: QuizOption[]
  startLabel?: string
  /** Quiz page. The chosen goal is appended as ?goal=value. */
  href?: string
  onStart?: (goal: string) => void
  meta?: string
  proof?: string
  /** Up to three product images for the visual side. */
  images?: { src: string; alt: string }[]
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("hero", "hero-quiz")
  const name = useId()
  const [goal, setGoal] = useState<string | null>(null)
  const target = goal ? `${href}${href.includes("?") ? "&" : "?"}goal=${encodeURIComponent(goal)}` : href

  return (
    <section ref={sk.ref} {...sk.attrs} className={cn("bg-sk-paper text-sk-ink", className)}>
      <div className="mx-auto grid w-full max-w-6xl items-center gap-10 px-4 py-10 sm:px-8 sm:py-14 lg:grid-cols-[1.15fr_0.85fr] lg:gap-14">
        <motion.div initial={{ opacity: 0, y: reduce ? 0 : 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, ease: EASE }}>
          <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{eyebrow}</p>
          <h1 className="mt-4 max-w-xl text-balance font-sk-display font-semibold leading-[1.08] tracking-[-0.02em]" style={{ fontSize: "clamp(2rem, 4.2vw, 3.25rem)" }}>
            {headline}
          </h1>

          <fieldset className="mt-8">
            <legend className="text-[15px] font-medium text-sk-ink">{question}</legend>
            <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3">
              {options.map((o) => {
                const on = goal === o.value
                return (
                  <label
                    key={o.value}
                    className={cn(
                      "relative flex min-h-[64px] cursor-pointer flex-col justify-center rounded-sk-card border px-3.5 py-3 transition-[border-color,background-color,transform] duration-200 ease-sk-out active:scale-[0.98] has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-sk-accent has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-sk-paper",
                      on ? "border-sk-accent bg-sk-accent/[0.07]" : "border-sk-line bg-sk-card hover:border-sk-ink/30"
                    )}
                  >
                    <input
                      type="radio"
                      name={name}
                      value={o.value}
                      checked={on}
                      onChange={() => {
                        setGoal(o.value)
                        sk.emit("interact", `goal_${o.value}`)
                      }}
                      className="sr-only"
                    />
                    <span className="pr-6 text-[14px] font-medium leading-tight">{o.label}</span>
                    {o.hint ? <span className="mt-1 text-[12px] leading-tight text-sk-muted">{o.hint}</span> : null}
                    <motion.span
                      aria-hidden
                      initial={false}
                      animate={{ scale: on ? 1 : 0.4, opacity: on ? 1 : 0 }}
                      transition={{ type: "spring", stiffness: 520, damping: 30 }}
                      className="absolute right-2.5 top-2.5 grid h-5 w-5 place-items-center rounded-full bg-sk-accent text-sk-accent-ink"
                    >
                      <Check size={12} strokeWidth={3} />
                    </motion.span>
                  </label>
                )
              })}
            </div>
          </fieldset>

          <div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center">
            <a
              href={target}
              aria-disabled={!goal}
              onClick={(e) => {
                if (!goal) {
                  e.preventDefault()
                  return
                }
                sk.emit("convert", "quiz_start")
                if (onStart) {
                  e.preventDefault()
                  onStart(goal)
                }
              }}
              className={cn(
                "sk-btn group inline-flex min-h-12 items-center justify-center gap-2 py-2.5 text-center leading-tight rounded-sk-btn bg-sk-accent px-7 text-[15px] font-medium text-sk-accent-ink outline-none transition-[filter,transform,opacity] duration-200 ease-sk-out focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper",
                goal ? "hover:brightness-[1.06] active:scale-[0.97]" : "cursor-not-allowed opacity-50"
              )}
            >
              {startLabel}
              <ArrowRight size={16} aria-hidden className="transition-transform duration-300 ease-sk-out group-hover:translate-x-0.5" />
            </a>
            <p className="flex items-center gap-1.5 text-[13px] text-sk-muted">
              <Clock size={14} aria-hidden />
              {meta}
            </p>
          </div>
          {proof ? <p className="mt-5 border-t border-sk-line pt-4 text-[13px] text-sk-muted">{proof}</p> : null}
        </motion.div>

        {images.length ? (
          <div className="relative mx-auto aspect-square w-full max-w-[420px] lg:max-w-none">
            <div className="absolute inset-[6%] rounded-full bg-sk-surface" aria-hidden />
            {images.slice(0, 3).map((im, i) => {
              const pos = ["left-[2%] top-[18%] w-[52%] rotate-[-6deg]", "right-[2%] top-[6%] w-[48%] rotate-[5deg]", "left-[26%] bottom-[2%] w-[50%] rotate-[-1deg]"][i]
              return (
                <motion.img
                  key={im.src + i}
                  src={im.src}
                  alt={im.alt}
                  initial={{ opacity: 0, y: reduce ? 0 : 16 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ duration: 0.6, ease: EASE, delay: reduce ? 0 : 0.12 + i * 0.08 }}
                  className={cn("absolute aspect-square rounded-sk-card border border-sk-line bg-sk-card object-cover shadow-[0_30px_60px_-30px_rgb(var(--sk-ink)/0.45)]", pos)}
                />
              )
            })}
          </div>
        ) : null}
      </div>
    </section>
  )
}

Usage

The goal is appended to `href` as ?goal=, or passed to `onStart`.

Quiz entry hero, StoreKit