Getting Started
Install quiz-ui and assemble a working funnel.
This guide walks through registering the registry, installing components,
and assembling a funnel. If you'd rather see it running before reading,
check out the /example page first — it chains every
component described here into one funnel.
Requirements
- React 18+
- Tailwind CSS configured in your project
Every component is unstyled by default — colors, spacing, and borders are
entirely up to you, applied via className props. There's no bundled
stylesheet to import.
Installation
1. Register the registry
Add the @quiz-ui namespace to your project once:
pnpm dlx shadcn@latest registry add @quiz-ui This writes the registry's URL template into your project's
components.json. If you'd rather do it by hand, add it directly:
{
"registries": {
"@quiz-ui": "https://quiz-ui-phi.vercel.app/r/{name}.json"
}
}2. Install components
Install any component by name — its dependencies (the engine, the
className helper, and whichever @radix-ui/* packages it needs) resolve
and install automatically:
pnpm dlx shadcn@latest add @quiz-ui/quiz-choiceTo install everything used in this guide in one shot:
pnpm dlx shadcn@latest add @quiz-ui/quiz-root @quiz-ui/quiz-step @quiz-ui/quiz-progress \
@quiz-ui/quiz-transition @quiz-ui/quiz-navigation @quiz-ui/quiz-choice \
@quiz-ui/quiz-image-choice @quiz-ui/quiz-slider @quiz-ui/quiz-rating \
@quiz-ui/quiz-text-input @quiz-ui/quiz-email-input @quiz-ui/quiz-resultComponents land in components/ui/quiz/ (e.g.
src/components/ui/quiz/quiz-root.tsx). There's no barrel file — import
each component from its own path:
import { QuizRoot } from "@/components/ui/quiz/quiz-root";
import { QuizChoiceGroup } from "@/components/ui/quiz/quiz-choice";The two layers
quiz-core— headless: a state machine, branching resolution, and the hooks every component reads from (useQuizEngine,useQuizStep,useQuizAnswers). Installed automatically as a dependency of anything that needs it — you won't install it directly in most projects.- Components (
QuizRoot,QuizChoiceGroup,QuizSlider, ...) — styled shells over Radix UI primitives. Each one reads and writes the current step's answer on its own viauseQuizAnswers()— none of them take avalueoronChangeprop. You never wire that up yourself.
Define a funnel
A funnel is a plain, serializable QuizDefinition object — not JSX —
so it can live in a config file, come from a CMS, or be generated at
build time.
interface QuizDefinition {
id: string;
title?: string;
entry: string; // id of the first step
steps: QuizStepDefinition[];
}
interface QuizStepDefinition {
id: string; // unique, stable
type: string; // your own label — you decide what it means
props?: Record<string, unknown>; // forwarded to whatever renders this step
required?: boolean; // must be answered to advance — default true
next?: string; // fallback next step id
branch?: QuizBranchRule[]; // evaluated in order; first match wins
}Linear funnels
For a straight sequence, just chain steps with next and leave it off
the last one:
const quiz: QuizDefinition = {
id: "dev-quiz",
entry: "role",
steps: [
{
id: "role",
type: "choice",
next: "email",
props: {
question: "What best describes you?",
options: [
{ value: "frontend", label: "Frontend developer" },
{ value: "backend", label: "Backend developer" },
],
},
},
{
id: "email",
type: "email",
props: { question: "Where should we send your results?" },
// no `next` — the engine marks the funnel complete once the
// user advances past this step
},
],
};Render completion UI anywhere in the tree with <QuizResult> — it
renders nothing until the funnel's status flips to "complete".
Branching funnels
Use branch to send different users down different paths based on an
earlier answer:
const quiz: QuizDefinition = {
id: "fitness-goal",
entry: "goal",
steps: [
{
id: "goal",
type: "choice",
props: {
question: "What's your main goal?",
options: [
{ value: "strength", label: "Build strength" },
{ value: "cardio", label: "Improve cardio" },
],
},
branch: [
{ equals: "strength", goTo: "frequency" },
{ equals: "cardio", goTo: "distance" },
],
},
{
id: "frequency",
type: "rating",
next: "email",
props: {
question: "How many days a week can you train?",
scale: 6,
numeric: true,
},
},
{
id: "distance",
type: "slider",
next: "email",
props: { question: "Current weekly distance (km)?", min: 0, max: 50 },
},
{
id: "email",
type: "email",
required: true,
next: "result",
props: { question: "Where should we send your plan?" },
},
{ id: "result", type: "result", required: false, props: {} },
],
};The last step here is an explicit terminal step rather than an implicit
one — useful when you want the completion message to render through the
same QuizStep switch (so it still participates in QuizProgress /
QuizTransition) rather than appearing separately below
QuizNavigation. Give it required: false, since nothing will ever set
an answer for it — otherwise QuizNavigation's Next button waits
forever for an answer that can't come.
Assemble the UI
Compose QuizRoot with a QuizStep that switches on step.type to pick
the matching input:
"use client";
import { QuizRoot } from "@/components/ui/quiz/quiz-root";
import { QuizStep } from "@/components/ui/quiz/quiz-step";
import { QuizProgress } from "@/components/ui/quiz/quiz-progress";
import { QuizChoiceGroup } from "@/components/ui/quiz/quiz-choice";
import { QuizImageChoice } from "@/components/ui/quiz/quiz-image-choice";
import { QuizSlider } from "@/components/ui/quiz/quiz-slider";
import { QuizRating } from "@/components/ui/quiz/quiz-rating";
import { QuizTextInput } from "@/components/ui/quiz/quiz-text-input";
import { QuizEmailInput } from "@/components/ui/quiz/quiz-email-input";
import { QuizNavigation } from "@/components/ui/quiz/quiz-navigation";
import { QuizResult } from "@/components/ui/quiz/quiz-result";
export default function FunnelPage() {
return (
<QuizRoot definition={quiz} onComplete={(answers) => console.log(answers)}>
<QuizProgress />
<QuizStep>
{(step) => {
switch (step.type) {
case "choice":
return <QuizChoiceGroup options={step.props?.options as any} />;
case "image-choice":
return <QuizImageChoice options={step.props?.options as any} />;
case "slider":
return (
<QuizSlider
min={step.props?.min as number}
max={step.props?.max as number}
/>
);
case "rating":
return <QuizRating scale={step.props?.scale as number} numeric />;
case "text":
return <QuizTextInput />;
case "email":
return <QuizEmailInput />;
case "result":
return (
<QuizResult>
{(answers) => (
<p>Thanks! Sending your plan to {String(answers.email)}.</p>
)}
</QuizResult>
);
default:
return null;
}
}}
</QuizStep>
<QuizNavigation />
</QuizRoot>
);
}That's the whole pattern — every funnel you build is this same shape:
a QuizDefinition, and a switch with one case per type you used.
Component reference
| Component | Purpose | Key props |
|---|---|---|
QuizRoot | Owns the engine for its subtree via QuizProvider. | definition, onComplete(answers), className |
QuizStep | Renders the active step's question heading, plus whatever input you return from children. | children: node | (step) => node, showQuestion (default true), questionClassName, className |
QuizProgress | "Step N of M" label + a Radix progress bar. | showLabel, labelClassName, trackClassName, indicatorClassName, className |
QuizChoiceGroup / QuizChoice | Single- or multi-select answer cards (radio or checkbox depending on multiple). | options: {value, label, description?}[], multiple, itemClassName, activeItemClassName, indicatorClassName, labelClassName, descriptionClassName |
QuizImageChoice | Single-select grid of image cards. | options: {value, label, imageUrl}[], itemClassName, activeItemClassName, imageClassName, labelClassName |
QuizSlider | Single-thumb numeric range input. | min, max, step, defaultValue, formatValue(value), className, valueClassName, trackClassName, rangeClassName, thumbClassName |
QuizRating | Star or numeric rating scale. | scale (default 5), numeric (default false), className, itemClassName, activeItemClassName |
QuizTextInput | Free-text answer. Forwards a ref; accepts native <input> props. | label, wrapperClassName, labelClassName, className (applies to the <input> itself) |
QuizEmailInput | Same as QuizTextInput, plus built-in email format validation on blur. | Everything QuizTextInput has, plus invalidMessage, invalidClassName, errorMessageClassName |
QuizNavigation | Back / Next controls. Next is disabled automatically until the current step is answered. | nextLabel, backLabel, hideBack, className, backButtonClassName, nextButtonClassName |
QuizTransition | Remounts its subtree on every step change (keyed on step id) so animations replay. Ships with no animation by default. | children, className |
QuizResult | Renders its children once the funnel's status is "complete"; renders nothing before that. | children: node | (answers) => node, className |
QuizDialog | Wraps a funnel in a Radix Dialog instead of showing it inline. Centering is the only baked-in default — everything else is styled via className. | trigger, children, className (content), overlayClassName, closeButtonClassName, closeButtonLabel, plus any Dialog.Root prop (open, onOpenChange, ...) |
Every *ClassName prop above is optional and purely visual — omit any
of them and that piece renders with no styling at all rather than a
built-in default, so check this table (or the file itself, once
installed) before assuming something looks a certain way out of the box.
Step transition animation
QuizTransition doesn't ship a default animation — it only handles the
remount that makes an animation possible. Add a keyframe to your global
stylesheet once, then pass its class as className:
@layer utilities {
@keyframes quiz-ui-step-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-quiz-ui-step-in {
animation: quiz-ui-step-in 200ms ease-out;
}
}<QuizTransition className="animate-quiz-ui-step-in">
<QuizStep>...</QuizStep>
</QuizTransition>Next.js note
Every quiz-ui component needs "use client" — this is a fully
client-side library (React state, context, event handlers). It's already
included in every file the CLI installs. The one thing you need to
remember: any page or component you write that renders <QuizRoot onComplete={...}> with an inline callback needs "use client" at its
own top too, or you'll hit "Event handlers cannot be passed to Client
Component props."
Next steps
- Browse the sidebar for the full prop reference on each component.
- Try
/exampleto see a complete funnel end to end, includingQuizImageChoice,QuizSlider,QuizRating, andQuizTransitiontogether.