A multi-step questionnaire with single-choice, multiple-choice, freeform and skippable questions, answer shortcuts, validation and native form data, built on shadcn's headless Questionnaire so the API is the same.
"use client"
import * as React from "react"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ballmac/questionnaire"
const items = [
{
name: "direction",
required: true,
prompt: "What should we prototype next?",
description: "Choose a direction or write your own.",
choices: [
{ value: "delegation", label: "Delegation", description: "Show how work moves to a specialist." },
{ value: "questions", label: "Question prompts", description: "Show choices while the interface waits." },
{ value: "both", label: "Both together" },
],
input: { label: "Another answer", placeholder: "Type another answer…" },
},
{
name: "detail",
required: false,
prompt: "How much detail should it include?",
description: "Skip this if you are not sure yet.",
choices: [
{ value: "focused", label: "Focused" },
{ value: "complete", label: "Complete flow" },
],
},
] as const
export default function QuestionnaireDemo() {
const [answers, setAnswers] = React.useState<Record<string, string> | null>(null)
return (
<div className="grid w-full max-w-md gap-4">
<Questionnaire
items={items}
onSubmit={(event) => {
event.preventDefault()
setAnswers(Object.fromEntries([...new FormData(event.currentTarget)].map(([k, v]) => [k, String(v)])))
}}
>
<QuestionnaireProgress />
{items.map((question) => (
<QuestionnaireItem key={question.name} name={question.name} required={question.required}>
<QuestionnaireTitle>{question.prompt}</QuestionnaireTitle>
<QuestionnaireDescription>{question.description}</QuestionnaireDescription>
<QuestionnaireChoices>
{question.choices.map((choice) => (
<QuestionnaireChoice key={choice.value} value={choice.value}>
<span className="font-medium">{choice.label}</span>
{"description" in choice ? <QuestionnaireChoiceDescription>{choice.description}</QuestionnaireChoiceDescription> : null}
</QuestionnaireChoice>
))}
{"input" in question ? <QuestionnaireInput aria-label={question.input.label} placeholder={question.input.placeholder} /> : null}
</QuestionnaireChoices>
<QuestionnaireError>Choose an answer to continue.</QuestionnaireError>
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireSkip />
<QuestionnaireNext />
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>
{answers ? (
<pre dir="ltr" className="overflow-x-auto rounded-lg border bg-muted p-3 font-mono text-xs" aria-live="polite">
{JSON.stringify(answers, null, 2)}
</pre>
) : null}
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/questionnaireInstall the dependencies.
$ pnpm add @shadcn/react@^0.3 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/i18nCopy the source into your project.
components/ballmac/questionnaire.tsx// Ballmac UI: Questionnaire. https://ui.ballmac.com/components/questionnaire // Based on shadcn/ui's Questionnaire (MIT, Copyright (c) 2023 shadcn) on @shadcn/react (MIT), the same parts and props, restyled with tokens and translated action labels. "use client" import * as React from "react" import { Questionnaire as Primitive } from "@shadcn/react/questionnaire" import { Check } from "lucide-react" import { buttonVariants, type ButtonProps } from "@/components/ballmac/button" import { useMessages } from "@/lib/ballmac/i18n" import { cn } from "@/lib/utils" /** The form that owns the items, the active one, the answers, validation and progress. Pass `items` to render the right item and its shortcuts on the server. */ function Questionnaire({ className, ...props }: React.ComponentProps<typeof Primitive.Root>) { return <Primitive.Root data-slot="questionnaire" className={cn("flex w-full min-w-0 flex-col gap-5", className)} {...props} /> } /** "Question 2 of 5", in the active item's place. It is a named progress bar for assistive technology. */ function QuestionnaireProgress({ className, ...props }: React.ComponentProps<typeof Primitive.Progress>) { return <Primitive.Progress data-slot="questionnaire-progress" className={cn("min-h-[1lh] w-fit min-w-[14ch] text-sm font-medium tabular-nums text-muted-foreground", className)} {...props} /> } /** One question: a fieldset, so its title is the legend that names the group of answers. */ function QuestionnaireItem({ className, ...props }: React.ComponentProps<typeof Primitive.Item>) { return <Primitive.Item data-slot="questionnaire-item" className={cn("min-w-0 border-0 p-0 outline-none", className)} {...props} /> } function QuestionnaireTitle({ className, ...props }: React.ComponentProps<typeof Primitive.Title>) { return <Primitive.Title data-slot="questionnaire-title" className={cn("text-pretty text-lg font-semibold leading-snug tracking-tight", className)} {...props} /> } function QuestionnaireDescription({ className, ...props }: React.ComponentProps<typeof Primitive.Description>) { return <Primitive.Description data-slot="questionnaire-description" className={cn("mt-1 text-pretty text-sm text-muted-foreground", className)} {...props} /> } /** The answers of one question. */ function QuestionnaireChoices({ className, ...props }: React.ComponentProps<typeof Primitive.Choices>) { return <Primitive.Choices data-slot="questionnaire-choices" className={cn("group/questionnaire-choices mt-4 grid min-w-0 gap-2", className)} {...props} /> } /** * One fixed answer: a real radio button or checkbox that the label covers, so the keyboard, the arrow keys and screen readers behave as the browser * does. Put the answer's words in it, and a `QuestionnaireChoiceDescription` for a second line. */ function QuestionnaireChoice({ children, className, ...props }: React.ComponentProps<typeof Primitive.Choice>) { return ( <Primitive.Choice data-slot="questionnaire-choice" className={cn( "group/questionnaire-choice relative flex min-h-11 cursor-pointer select-none items-start gap-3 rounded-lg border bg-background px-3 py-2.5 text-start text-sm shadow-xs outline-none transition-[border-color,background-color,box-shadow] duration-150 motion-reduce:transition-none", "hover:bg-accent/50 has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50", "data-checked:border-primary data-checked:bg-primary/5 data-invalid:border-destructive/60", "data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50", className )} {...props} > <Primitive.ChoiceInput data-slot="questionnaire-choice-input" className="absolute inset-0 z-10 size-full cursor-pointer opacity-0" /> <span aria-hidden="true" data-slot="questionnaire-choice-indicator" className="pointer-events-none relative mt-0.5 flex size-4 shrink-0 items-center justify-center border border-input bg-background text-primary-foreground transition-colors group-data-[type=radio]/questionnaire-choice:rounded-full group-data-[type=checkbox]/questionnaire-choice:rounded-[4px] group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary motion-reduce:transition-none" > <span className="hidden size-1.5 rounded-full bg-primary-foreground group-data-[type=radio]/questionnaire-choice:group-data-checked/questionnaire-choice:block" /> <Check className="hidden size-3 group-data-[type=checkbox]/questionnaire-choice:group-data-checked/questionnaire-choice:block" strokeWidth={3} /> </span> <Primitive.ChoiceLabel data-slot="questionnaire-choice-label" className="flex min-w-0 flex-1 flex-col gap-0.5 leading-snug"> {children} </Primitive.ChoiceLabel> <Primitive.ChoiceShortcut data-slot="questionnaire-choice-shortcut" className="pointer-events-none ms-auto hidden shrink-0 rounded border bg-muted px-1.5 font-mono text-[11px] uppercase text-muted-foreground group-data-[shortcut]/questionnaire-choice:inline-flex" /> </Primitive.Choice> ) } /** A second line inside a choice. */ function QuestionnaireChoiceDescription({ className, ...props }: React.ComponentProps<"span">) { return <span data-slot="questionnaire-choice-description" className={cn("text-muted-foreground", className)} {...props} /> } /** A text answer next to the fixed ones. Always give it a name with a label, `aria-label` or `aria-labelledby`: a placeholder is not one. */ function QuestionnaireInput({ className, ...props }: React.ComponentProps<typeof Primitive.Input>) { return ( <div data-slot="questionnaire-input-wrapper" className="group/questionnaire-input relative min-w-0"> <Primitive.Input data-slot="questionnaire-input" className={cn( "min-h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-xs outline-none transition-[color,border-color,box-shadow] duration-150 placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground", "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 motion-reduce:transition-none sm:min-h-10", className )} {...props} /> </div> ) } /** The message when an answer is required, or refused. It is tied to the item, and shown only while the item is invalid. */ function QuestionnaireError({ className, ...props }: React.ComponentProps<typeof Primitive.Error>) { return <Primitive.Error data-slot="questionnaire-error" className={cn("mt-2 text-sm text-destructive", className)} {...props} /> } /** The row of buttons: Previous at the start, then Skip, then Next or Submit at the end. */ function QuestionnaireActions({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="questionnaire-actions" className={cn("grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2", className)} {...props} /> } type ActionStyle = Pick<ButtonProps, "size" | "variant"> function QuestionnairePrevious({ children, className, size = "default", variant = "outline", ...props }: React.ComponentProps<typeof Primitive.Previous> & ActionStyle) { const msg = useMessages() return ( <Primitive.Previous data-slot="questionnaire-previous" data-size={size} data-variant={variant} className={cn(buttonVariants({ size, variant }), "col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0", className)} {...props}> {children ?? msg("questionnaire.previous", "Previous")} </Primitive.Previous> ) } function QuestionnaireSkip({ children, className, size = "default", variant = "ghost", ...props }: React.ComponentProps<typeof Primitive.Skip> & ActionStyle) { const msg = useMessages() return ( <Primitive.Skip data-slot="questionnaire-skip" data-size={size} data-variant={variant} className={cn(buttonVariants({ size, variant }), "col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0", className)} {...props}> {children ?? msg("questionnaire.skip", "Skip")} </Primitive.Skip> ) } function QuestionnaireNext({ children, className, size = "default", variant = "default", ...props }: React.ComponentProps<typeof Primitive.Next> & ActionStyle) { const msg = useMessages() return ( <Primitive.Next data-slot="questionnaire-next" data-size={size} data-variant={variant} className={cn(buttonVariants({ size, variant }), "col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0", className)} {...props}> {children ?? msg("questionnaire.next", "Next")} </Primitive.Next> ) } function QuestionnaireSubmit({ children, className, size = "default", variant = "default", ...props }: React.ComponentProps<typeof Primitive.Submit> & ActionStyle) { const msg = useMessages() return ( <Primitive.Submit data-slot="questionnaire-submit" data-size={size} data-variant={variant} className={cn(buttonVariants({ size, variant }), "col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0", className)} {...props}> {children ?? msg("questionnaire.submit", "Submit")} </Primitive.Submit> ) } export { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle, }Update the import paths to match your project setup.
Usage
import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle } from "@/components/ballmac/questionnaire"The full example is in the Code tab above.
Examples
Multiple selection
"use client"
import * as React from "react"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ballmac/questionnaire"
const signals = ["Progress", "Decisions", "Risks", "Next steps"]
export default function QuestionnaireMultiple() {
const [picked, setPicked] = React.useState<string[] | null>(null)
return (
<div className="grid w-full max-w-md gap-4">
<Questionnaire
onSubmit={(e) => {
e.preventDefault()
setPicked(new FormData(e.currentTarget).getAll("signals").map(String))
}}
>
<QuestionnaireItem name="signals" multiple required>
<QuestionnaireTitle>What should every update include?</QuestionnaireTitle>
<QuestionnaireDescription>Select all that apply.</QuestionnaireDescription>
<QuestionnaireChoices>
{signals.map((s) => (
<QuestionnaireChoice key={s} value={s.toLowerCase()}>
{s}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Choose at least one.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>
{picked ? <p aria-live="polite" className="text-sm text-muted-foreground">You chose: {picked.join(", ")}</p> : null}
</div>
)
}Letter shortcuts
"use client"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ballmac/questionnaire"
const areas = ["Public API", "Test coverage", "Accessibility", "Performance"]
/** Press the letter beside an answer to choose it. Enter moves on. */
export default function QuestionnaireShortcuts() {
return (
<Questionnaire shortcuts="letters" className="w-full max-w-md" onSubmit={(e) => e.preventDefault()}>
<QuestionnaireItem name="review" required>
<QuestionnaireTitle>What should the agent review?</QuestionnaireTitle>
<QuestionnaireDescription>Press a letter to choose, then Enter.</QuestionnaireDescription>
<QuestionnaireChoices>
{areas.map((a) => (
<QuestionnaireChoice key={a} value={a.toLowerCase().replace(" ", "-")}>
{a}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>
)
}Optional and skippable
"use client"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ballmac/questionnaire"
const items = [
{ name: "team", required: true, prompt: "Who is this for?", choices: ["Just me", "My team", "Customers"] },
{ name: "budget", required: false, prompt: "Is there a budget?", description: "Optional. Skip if you do not know yet.", choices: ["Under $1k", "$1k to $10k", "More than $10k"] },
] as const
/** An optional question can be skipped on purpose, which counts as an answer. */
export default function QuestionnaireSkipExample() {
return (
<Questionnaire items={items.map((i) => ({ name: i.name, required: i.required, choices: i.choices.map((c) => ({ value: c })) }))} className="w-full max-w-md" onSubmit={(e) => e.preventDefault()}>
<QuestionnaireProgress />
{items.map((q) => (
<QuestionnaireItem key={q.name} name={q.name} required={q.required}>
<QuestionnaireTitle>{q.prompt}</QuestionnaireTitle>
{"description" in q ? <QuestionnaireDescription>{q.description}</QuestionnaireDescription> : null}
<QuestionnaireChoices>
{q.choices.map((c) => (
<QuestionnaireChoice key={c} value={c}>
{c}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireSkip />
<QuestionnaireNext />
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>
)
}Your own validation
"use client"
import * as React from "react"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ballmac/questionnaire"
const items = [
{ name: "scope", prompt: "What should the project include?", choices: ["Design", "Build", "Launch"] },
{ name: "detail", prompt: "How much detail?", choices: ["Focused", "Complete"] },
] as const
/** Your own validation, for rules the form cannot know: set `invalid` on the item, show the message in the error, and move the active item to the first problem. */
export default function QuestionnaireValidation() {
const [item, setItem] = React.useState("scope")
const [errors, setErrors] = React.useState<Record<string, string>>({})
const [done, setDone] = React.useState(false)
return (
<Questionnaire
item={item}
onItemChange={setItem}
noValidate
onChange={() => setErrors({})}
className="w-full max-w-md"
onSubmit={(e) => {
e.preventDefault()
const data = new FormData(e.currentTarget)
const next: Record<string, string> = {}
if (data.get("scope") === "Launch" && data.get("detail") === "Focused") next.detail = "A launch needs the complete flow."
setErrors(next)
const first = items.find((q) => next[q.name])
if (first) return setItem(first.name)
setDone(true)
}}
>
<QuestionnaireProgress />
{items.map((q) => (
<QuestionnaireItem key={q.name} name={q.name} required invalid={Boolean(errors[q.name])}>
<QuestionnaireTitle>{q.prompt}</QuestionnaireTitle>
<QuestionnaireChoices>
{q.choices.map((c) => (
<QuestionnaireChoice key={c} value={c}>
{c}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>{errors[q.name]}</QuestionnaireError>
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext />
<QuestionnaireSubmit />
</QuestionnaireActions>
{done ? <p role="status" className="text-sm text-muted-foreground">Thanks, that is everything.</p> : null}
</Questionnaire>
)
}In a card
"use client"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ballmac/card"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ballmac/questionnaire"
/** In a card: the card owns the frame, the questionnaire owns the questions. */
export default function QuestionnaireCard() {
return (
<Card className="w-full max-w-md">
<Questionnaire onSubmit={(e) => e.preventDefault()} className="gap-0">
<CardHeader>
<CardTitle>Before we start</CardTitle>
<CardDescription>Two quick questions so the first run fits you.</CardDescription>
</CardHeader>
<CardContent className="py-4">
<QuestionnaireItem name="stack" required>
<QuestionnaireTitle className="text-base">Which framework do you use?</QuestionnaireTitle>
<QuestionnaireChoices>
<QuestionnaireChoice value="next">Next.js</QuestionnaireChoice>
<QuestionnaireChoice value="vite">Vite</QuestionnaireChoice>
<QuestionnaireChoice value="other">Something else</QuestionnaireChoice>
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
</CardContent>
<CardFooter>
<QuestionnaireActions>
<QuestionnaireSubmit>Start</QuestionnaireSubmit>
</QuestionnaireActions>
</CardFooter>
</Questionnaire>
</Card>
)
}Accessibility
| Key | Action |
|---|---|
| Tab / arrow keys | Native radio and checkbox behavior in each question |
| Letter or number keys | With shortcuts set, choose the answer with that key; it does not advance |
| Enter | Next on a question, Submit on the last |
| Screen readers | Each question is a fieldset named by its legend; descriptions and errors are linked; progress is a named progress bar; inactive questions are inert |
| After a failed step | Focus goes to an answer of the question that needs one, and its error is announced |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 6 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
16 tab stops, each showing a visible change when it takes focus. 5 documented keys.
Not applicable: Loading, empty and error
It collects answers and hands them to onSubmit as form data; saving them, and any loading or failure around that, belongs to the page that owns the form.
For information: Automated tests
No test file of its own yet.
For information: Footprint
2.4 KB of source (gzip) in 1 file. Installs @shadcn/react, lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
<Questionnaire items onSubmit> with a <QuestionnaireItem name required> per question: Title, Description, Choices holding <QuestionnaireChoice value> and optionally <QuestionnaireInput aria-label>, and an Error. Add <QuestionnaireActions> with Previous, Skip, Next and Submit. Answers are native FormData. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Questionnaire (@ballmac/questionnaire) to this project with the shadcn MCP, then use it where it fits.
Use it for
- An AI agent asking the person to choose between options before it continues
- Onboarding and intake steps with a few questions
- Surveys where answers become form data
Not for
- One question inside a form; use radio-group or checkbox
- A long form with many field types; use form blocks
- Quizzes that score answers; compute that yourself from the form data
Registry JSON: https://ui.ballmac.com/r/questionnaire.json
Credits
Based on shadcn/ui Questionnaire, adapted by Ballmac. Free to use in personal and commercial projects.
Pairs well with
Card
A composable content surface with compact spacing, an action slot, and optional interactive feedback.
Dialog
A Radix modal dialog with a blurred overlay, centered card surface that fits 360 px screens, header and footer parts, optional close button and animations.
Trace
The one row behind reasoning, tool calls, sources and plans: an icon, a title and optional detail, with running, error and pending states, rows that expand, link or open a dialog, and shadcn's Trace API.
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.