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-quizhero-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-quizSource
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`.


