quiz-ui

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:

components.json
{
  "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-choice

To 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-result

Components 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 via useQuizAnswers() — none of them take a value or onChange prop. 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

ComponentPurposeKey props
QuizRootOwns the engine for its subtree via QuizProvider.definition, onComplete(answers), className
QuizStepRenders 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 / QuizChoiceSingle- or multi-select answer cards (radio or checkbox depending on multiple).options: {value, label, description?}[], multiple, itemClassName, activeItemClassName, indicatorClassName, labelClassName, descriptionClassName
QuizImageChoiceSingle-select grid of image cards.options: {value, label, imageUrl}[], itemClassName, activeItemClassName, imageClassName, labelClassName
QuizSliderSingle-thumb numeric range input.min, max, step, defaultValue, formatValue(value), className, valueClassName, trackClassName, rangeClassName, thumbClassName
QuizRatingStar or numeric rating scale.scale (default 5), numeric (default false), className, itemClassName, activeItemClassName
QuizTextInputFree-text answer. Forwards a ref; accepts native <input> props.label, wrapperClassName, labelClassName, className (applies to the <input> itself)
QuizEmailInputSame as QuizTextInput, plus built-in email format validation on blur.Everything QuizTextInput has, plus invalidMessage, invalidClassName, errorMessageClassName
QuizNavigationBack / Next controls. Next is disabled automatically until the current step is answered.nextLabel, backLabel, hideBack, className, backButtonClassName, nextButtonClassName
QuizTransitionRemounts its subtree on every step change (keyed on step id) so animations replay. Ships with no animation by default.children, className
QuizResultRenders its children once the funnel's status is "complete"; renders nothing before that.children: node | (answers) => node, className
QuizDialogWraps 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 /example to see a complete funnel end to end, including QuizImageChoice, QuizSlider, QuizRating, and QuizTransition together.

On this page