LLM index: /llms.txt
Composable card for follow-up clarification questions in chat—when the model (or your app) needs structured input before continuing. Pass items for synchronous question metadata (title, pagination, navigation), compose each slide with Question, QuestionOption, and optional QuestionOther, then handle onSubmit with a typed submission array.
Single-choice answers auto-advance to the next question (except on the last slide, where Submit is required). Skip moves past optional unanswered questions without storing an answer. Skipped or unanswered optional questions are emitted with status skipped and answer [No Preference]. Only explicit selections are kept in internal state. Submit is disabled only while a required question is unanswered—hide it until the last slide in multi-question flows.
"use client";
import {
Question,
QuestionOption,
QuestionOptions,
QuestionOther,
type QuestionInput,
Questions,
QuestionsDismiss,
QuestionsFooter,
QuestionsHeader,
QuestionsSubmit,
QuestionsTitle,
} from "@/components/nexus-ui/questions";
import { Toaster } from "@/components/nexus-ui/toaster";
import { toastSubmission } from "@/components/nexus-ui/examples/questions/submission-toast";
const TOASTER_ID = "questions-default";
const QUESTION: QuestionInput = {
id: "depth",
type: "single",
prompt: "How detailed should the explanation be?",
options: [
{ value: "brief", label: "Brief overview" },
{ value: "standard", label: "Standard depth" },
{ value: "deep", label: "Deep dive with examples" },
],
};
function QuestionsDefault() {
return (
<div className="w-full">
<Questions
items={[QUESTION]}
onSubmit={(submission) => toastSubmission(submission, TOASTER_ID)}
>
<QuestionsHeader>
<QuestionsTitle />
<QuestionsDismiss />
</QuestionsHeader>
<Question id={QUESTION.id}>
<QuestionOptions>
{QUESTION.options.map((option) => (
<QuestionOption key={option.value} value={option.value}>
{option.label}
</QuestionOption>
))}
<QuestionOther />
</QuestionOptions>
</Question>
<QuestionsFooter>
<QuestionsSubmit />
</QuestionsFooter>
</Questions>
<Toaster id={TOASTER_ID} />
</div>
);
}
export default QuestionsDefault;
Installation
npx shadcn@latest add @nexus-ui/questionspnpm dlx shadcn@latest add @nexus-ui/questionsyarn dlx shadcn@latest add @nexus-ui/questionsbunx shadcn@latest add @nexus-ui/questionsInstall the following dependencies:
npx shadcn@latest add carousel button card checkbox && npm install @hugeicons/react @hugeicons/core-free-iconspnpm dlx shadcn@latest add carousel button card checkbox && pnpm add @hugeicons/react @hugeicons/core-free-iconsyarn dlx shadcn@latest add carousel button card checkbox && yarn add @hugeicons/react @hugeicons/core-free-iconsbunx shadcn@latest add carousel button card checkbox && bun add @hugeicons/react @hugeicons/core-free-iconsCopy and paste the following code into your project.
"use client";
import * as React from "react";
import {
ArrowLeft01Icon,
ArrowRight01Icon,
Cancel01Icon,
Edit03Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from "@/components/ui/carousel";
import { cn } from "@/lib/utils";
export type QuestionType = "single" | "multiple";
export type QuestionOptionInput = {
value: string;
label: React.ReactNode;
};
export type QuestionInput = {
id: string;
type: QuestionType;
prompt: React.ReactNode;
options: QuestionOptionInput[];
required?: boolean;
};
export const QUESTION_OTHER_VALUE = "__other__";
export const QUESTION_NO_PREFERENCE_VALUE = "__no_preference__";
export const QUESTION_NO_PREFERENCE_LABEL = "[No Preference]";
export type RegisteredQuestion = {
id: string;
type: QuestionType;
prompt: React.ReactNode;
required?: boolean;
index: number;
options?: QuestionOptionInput[];
};
type QuestionScope = {
id: string;
type: QuestionType;
};
export type QuestionSubmissionAnswer = {
value: string;
label: React.ReactNode;
};
const NO_PREFERENCE_ANSWER: QuestionSubmissionAnswer = {
value: QUESTION_NO_PREFERENCE_VALUE,
label: QUESTION_NO_PREFERENCE_LABEL,
};
export type SingleQuestionAnswerState = {
type: "single";
value: string;
other?: string;
};
export type MultipleQuestionAnswerState = {
type: "multiple";
value: string[];
other?: string;
};
export type QuestionAnswerState =
| SingleQuestionAnswerState
| MultipleQuestionAnswerState;
export type QuestionsSubmission = Array<
| {
questionId: string;
prompt: React.ReactNode;
type: "single";
status: "answered";
answer: QuestionSubmissionAnswer;
}
| {
questionId: string;
prompt: React.ReactNode;
type: "single";
status: "skipped";
answer: QuestionSubmissionAnswer;
}
| {
questionId: string;
prompt: React.ReactNode;
type: "multiple";
status: "answered";
answer: QuestionSubmissionAnswer[];
}
| {
questionId: string;
prompt: React.ReactNode;
type: "multiple";
status: "skipped";
answer: QuestionSubmissionAnswer[];
}
>;
function isQuestionAnswered(
question: RegisteredQuestion,
answer: QuestionAnswerState | undefined,
): boolean {
if (!answer || answer.type !== question.type) return false;
if (question.type === "single") {
if (answer.value === QUESTION_OTHER_VALUE) {
return Boolean(answer.other?.trim());
}
return Boolean(answer.value);
}
return answer.value.length > 0 || Boolean(answer.other?.trim());
}
function isBlockedByRequired(
question: RegisteredQuestion | undefined,
answers: Record<string, QuestionAnswerState>,
): boolean {
return Boolean(
question?.required && !isQuestionAnswered(question, answers[question.id]),
);
}
function isOtherAnswer(
answer: QuestionAnswerState | undefined,
type: QuestionType,
): boolean {
if (!answer || answer.type !== type) return false;
if (type === "single") return answer.value === QUESTION_OTHER_VALUE;
return (
answer.value.includes(QUESTION_OTHER_VALUE) || Boolean(answer.other?.trim())
);
}
function canSubmitQuestions(
questions: RegisteredQuestion[],
answers: Record<string, QuestionAnswerState>,
): boolean {
if (questions.length === 0) return false;
return !questions.some(
(question) =>
question.required && !isQuestionAnswered(question, answers[question.id]),
);
}
function optionLabel(
question: RegisteredQuestion,
value: string,
other?: string,
): React.ReactNode {
if (value === QUESTION_OTHER_VALUE) {
return other?.trim() || "Other";
}
return question.options?.find((option) => option.value === value)?.label ?? value;
}
function skippedSubmission(
question: RegisteredQuestion,
): QuestionsSubmission[number] {
return question.type === "single"
? {
questionId: question.id,
prompt: question.prompt,
type: "single",
status: "skipped",
answer: NO_PREFERENCE_ANSWER,
}
: {
questionId: question.id,
prompt: question.prompt,
type: "multiple",
status: "skipped",
answer: [NO_PREFERENCE_ANSWER],
};
}
function buildSubmission(
questions: RegisteredQuestion[],
answers: Record<string, QuestionAnswerState>,
): QuestionsSubmission {
return questions.map((question) => {
const answer = answers[question.id];
if (!isQuestionAnswered(question, answer)) {
return skippedSubmission(question);
}
if (question.type === "single" && answer?.type === "single") {
return {
questionId: question.id,
prompt: question.prompt,
type: "single",
status: "answered",
answer: {
value: answer.value,
label: optionLabel(question, answer.value, answer.other),
},
};
}
if (question.type === "multiple" && answer?.type === "multiple") {
const submissionAnswers = answer.value.map((value) => ({
value,
label: optionLabel(question, value, answer.other),
}));
if (answer.other?.trim() && !answer.value.includes(QUESTION_OTHER_VALUE)) {
submissionAnswers.push({
value: QUESTION_OTHER_VALUE,
label: answer.other.trim(),
});
}
return {
questionId: question.id,
prompt: question.prompt,
type: "multiple",
status: "answered",
answer: submissionAnswers,
};
}
return skippedSubmission(question);
});
}
function createQuestionsFromItems(items: QuestionInput[]): RegisteredQuestion[] {
return items.map((item, index) => ({
id: item.id,
type: item.type,
prompt: item.prompt,
required: item.required ?? false,
index,
options: item.options,
}));
}
type QuestionsRootContextValue = {
questions: RegisteredQuestion[];
index: number;
answers: Record<string, QuestionAnswerState>;
selectSingle: (
questionId: string,
value: string,
other?: string,
options?: { autoAdvance?: boolean },
) => void;
toggleMultiple: (questionId: string, value: string) => void;
setMultipleOther: (questionId: string, other: string) => void;
clearAnswer: (questionId: string) => void;
skip: () => void;
submit: () => void;
goNext: () => void;
goPrev: () => void;
carouselApi: CarouselApi | null;
setCarouselApi: (api: CarouselApi | undefined) => void;
onDismiss?: () => void;
};
const QuestionsRootContext =
React.createContext<QuestionsRootContextValue | null>(null);
const QuestionContext = React.createContext<QuestionScope | null>(null);
function useQuestionsRoot(component: string): QuestionsRootContextValue {
const ctx = React.useContext(QuestionsRootContext);
if (!ctx) {
throw new Error(`${component} must be used within Questions`);
}
return ctx;
}
function useQuestion(component: string): QuestionScope {
const ctx = React.useContext(QuestionContext);
if (!ctx) {
throw new Error(`${component} must be used within Question`);
}
return ctx;
}
export type QuestionsProps = Omit<React.ComponentProps<typeof Card>, "onSubmit"> & {
items: QuestionInput[];
autoAdvance?: boolean;
onSubmit?: (submission: QuestionsSubmission) => void;
onSkip?: (questionId: string) => void;
onDismiss?: () => void;
};
function Questions({
className,
items,
autoAdvance = true,
onSubmit,
onSkip,
onDismiss,
children,
...props
}: QuestionsProps) {
const questions = React.useMemo(() => createQuestionsFromItems(items), [items]);
const [index, setIndex] = React.useState(0);
const [answers, setAnswers] = React.useState<Record<string, QuestionAnswerState>>(
{},
);
const answersRef = React.useRef(answers);
const [carouselApi, setCarouselApi] = React.useState<CarouselApi | null>(null);
const questionCount = questions.length;
const clampedIndex =
questionCount === 0 ? 0 : Math.min(Math.max(0, index), questionCount - 1);
const goToIndex = React.useCallback(
(nextIndex: number) => {
if (questionCount === 0) return;
const target = Math.min(Math.max(0, nextIndex), questionCount - 1);
setIndex(target);
carouselApi?.scrollTo(target);
},
[carouselApi, questionCount],
);
const clearAnswer = React.useCallback((questionId: string) => {
setAnswers((prev) => {
if (!(questionId in prev)) return prev;
const next = { ...prev };
delete next[questionId];
answersRef.current = next;
return next;
});
}, []);
const selectSingle = React.useCallback(
(
questionId: string,
value: string,
other?: string,
options?: { autoAdvance?: boolean },
) => {
setAnswers((prev) => {
const next = {
...prev,
[questionId]: {
type: "single" as const,
value,
...(other ? { other } : {}),
},
};
answersRef.current = next;
return next;
});
if (options?.autoAdvance === false || !autoAdvance) return;
const questionIndex = questions.findIndex((q) => q.id === questionId);
if (questionIndex < 0 || questionIndex >= questions.length - 1) return;
goToIndex(questionIndex + 1);
},
[autoAdvance, goToIndex, questions],
);
const toggleMultiple = React.useCallback((questionId: string, value: string) => {
setAnswers((prev) => {
const existing = prev[questionId];
const currentValues =
existing?.type === "multiple" ? existing.value : [];
const nextValues = currentValues.includes(value)
? currentValues.filter((item) => item !== value)
: [...currentValues, value];
return {
...prev,
[questionId]: {
type: "multiple",
value: nextValues,
...(existing?.type === "multiple" && existing.other
? { other: existing.other }
: {}),
},
};
});
}, []);
const setMultipleOther = React.useCallback((questionId: string, other: string) => {
setAnswers((prev) => {
const existing = prev[questionId];
const currentValues = existing?.type === "multiple" ? existing.value : [];
return {
...prev,
[questionId]: {
type: "multiple",
value: currentValues,
other,
},
};
});
}, []);
const goNext = React.useCallback(() => {
const current = questions[clampedIndex];
if (!current || isBlockedByRequired(current, answers)) return;
if (clampedIndex < questionCount - 1) goToIndex(clampedIndex + 1);
}, [answers, clampedIndex, goToIndex, questionCount, questions]);
const goPrev = React.useCallback(() => {
if (clampedIndex > 0) {
goToIndex(clampedIndex - 1);
}
}, [clampedIndex, goToIndex]);
const skip = React.useCallback(() => {
const current = questions[clampedIndex];
if (!current || clampedIndex >= questionCount - 1) return;
if (isBlockedByRequired(current, answers)) return;
onSkip?.(current.id);
goToIndex(clampedIndex + 1);
}, [answers, clampedIndex, goToIndex, onSkip, questionCount, questions]);
const submit = React.useCallback(() => {
if (!canSubmitQuestions(questions, answers)) return;
onSubmit?.(buildSubmission(questions, answers));
answersRef.current = {};
setAnswers({});
goToIndex(0);
}, [answers, goToIndex, onSubmit, questions]);
React.useEffect(() => {
if (!carouselApi) return;
const onSelect = () => {
const newIndex = carouselApi.selectedScrollSnap();
const oldIndex = carouselApi.previousScrollSnap();
if (newIndex === oldIndex) return;
const oldQuestion = questions[oldIndex];
if (
newIndex > oldIndex &&
isBlockedByRequired(oldQuestion, answersRef.current)
) {
carouselApi.scrollTo(oldIndex);
return;
}
setIndex(newIndex);
};
carouselApi.on("select", onSelect);
return () => {
carouselApi.off("select", onSelect);
};
}, [carouselApi, questions]);
React.useEffect(() => {
if (!carouselApi) return;
if (carouselApi.selectedScrollSnap() !== clampedIndex) {
carouselApi.scrollTo(clampedIndex);
}
}, [carouselApi, clampedIndex]);
const rootValue = React.useMemo<QuestionsRootContextValue>(
() => ({
questions,
index: clampedIndex,
answers,
selectSingle,
toggleMultiple,
setMultipleOther,
clearAnswer,
skip,
submit,
goNext,
goPrev,
carouselApi,
setCarouselApi: (api) => setCarouselApi(api ?? null),
onDismiss,
}),
[
answers,
clampedIndex,
carouselApi,
clearAnswer,
goNext,
goPrev,
onDismiss,
questions,
selectSingle,
setMultipleOther,
skip,
submit,
toggleMultiple,
],
);
return (
<QuestionsRootContext.Provider value={rootValue}>
<Card
data-slot="questions"
className={cn(
"mx-auto w-full max-w-xl gap-0 rounded-3xl px-1 pt-4 pb-1! shadow-none shadow-border/50 dark:border-accent dark:shadow-background/50",
className,
)}
{...props}
>
{children}
</Card>
</QuestionsRootContext.Provider>
);
}
export type QuestionProps = {
id: string;
children?: React.ReactNode;
};
function Question({ id, children }: QuestionProps) {
const { questions } = useQuestionsRoot("Question");
const registered = questions.find((question) => question.id === id);
if (!registered) {
throw new Error(`Question "${id}" is not in Questions items`);
}
const scope = React.useMemo<QuestionScope>(
() => ({ id: registered.id, type: registered.type }),
[registered.id, registered.type],
);
return (
<QuestionContext.Provider value={scope}>
<CardContent data-slot="question" className="w-full p-1.5">
{children}
</CardContent>
</QuestionContext.Provider>
);
}
const questionOptionsListClassName =
"flex w-full flex-col gap-0.5 [&>*+*]:relative [&>*+*]:before:pointer-events-none [&>*+*]:before:absolute [&>*+*]:before:top-0 [&>*+*]:before:right-2.5 [&>*+*]:before:left-2.5 [&>*+*]:before:z-10 [&>*+*]:before:h-px [&>*+*]:before:bg-border/20 [&>*+*]:before:content-['']";
const questionRowClassName =
"group/row flex h-11 w-full items-center gap-2.5 rounded-lg bg-transparent px-2.5 text-left transition-all hover:bg-muted";
const questionOptionRowClassName = cn(questionRowClassName, "active:scale-99");
export type QuestionOptionsProps = React.HTMLAttributes<HTMLDivElement>;
function QuestionOptions({ className, children, ...props }: QuestionOptionsProps) {
const question = useQuestion("QuestionOptions");
return (
<div
data-slot="question-options"
role={question.type === "single" ? "listbox" : "group"}
className={cn(questionOptionsListClassName, className)}
{...props}
>
{React.Children.map(children, (child, optionIndex) => {
if (!React.isValidElement(child)) return child;
if (child.type !== QuestionOption) return child;
return React.cloneElement(
child as React.ReactElement<{ optionIndex?: number }>,
{ optionIndex },
);
})}
</div>
);
}
export type QuestionOptionProps = Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"value"
> & {
value: string;
optionIndex?: number;
children?: React.ReactNode;
};
function QuestionOption({
value,
optionIndex = 0,
className,
children,
onClick,
...props
}: QuestionOptionProps) {
const question = useQuestion("QuestionOption");
const root = useQuestionsRoot("QuestionOption");
const answer = root.answers[question.id];
const displayIndex = optionIndex + 1;
const isSelected =
question.type === "single"
? answer?.type === "single" && answer.value === value
: answer?.type === "multiple" && answer.value.includes(value);
const handleSelect = () => {
if (question.type === "single") {
if (isSelected) {
root.clearAnswer(question.id);
return;
}
root.selectSingle(question.id, value);
return;
}
root.toggleMultiple(question.id, value);
};
if (question.type === "multiple") {
return (
<label
data-slot="question-option"
className={cn(
questionOptionRowClassName,
"cursor-pointer",
isSelected && "bg-muted",
className,
)}
>
<Checkbox
checked={isSelected}
onCheckedChange={handleSelect}
className="mx-1.25 size-4.5 shadow-none transition-colors group-hover/row:data-[state=unchecked]:border-ring/50"
/>
<span className="min-w-0 flex-1 truncate text-sm text-ring transition-all group-hover/row:text-primary">
{children}
</span>
</label>
);
}
return (
<button
type="button"
role="option"
aria-selected={isSelected}
data-slot="question-option"
className={cn(
questionOptionRowClassName,
"cursor-pointer",
isSelected && "bg-muted",
className,
)}
onClick={(event) => {
onClick?.(event);
handleSelect();
}}
{...props}
>
<span
className={cn(
"relative flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md bg-border/30 transition-all group-hover/row:bg-border/70",
isSelected && "bg-border/70",
)}
>
<span
className={cn(
"text-sm text-muted-foreground transition-all group-hover/row:text-primary",
isSelected && "text-primary",
)}
>
{displayIndex}
</span>
</span>
<span
className={cn(
"min-w-0 flex-1 truncate text-sm text-ring transition-all group-hover/row:text-primary",
isSelected && "text-primary",
)}
>
{children}
</span>
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2.0}
className="size-4 text-muted-foreground opacity-0 transition-opacity group-hover/row:opacity-100"
/>
</button>
);
}
export type QuestionOtherProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange"
>;
function QuestionOther({
className,
placeholder = "Other...",
onKeyDown,
...props
}: QuestionOtherProps) {
const question = useQuestion("QuestionOther");
const root = useQuestionsRoot("QuestionOther");
const answer = root.answers[question.id];
const otherValue =
answer?.type === "single" || answer?.type === "multiple"
? (answer.other ?? "")
: "";
const isOtherSelected = isOtherAnswer(answer, question.type);
const handleOtherToggle = () => {
if (question.type === "single") {
if (otherValue.trim()) {
root.selectSingle(
question.id,
QUESTION_OTHER_VALUE,
otherValue.trim(),
{ autoAdvance: false },
);
}
return;
}
root.toggleMultiple(question.id, QUESTION_OTHER_VALUE);
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const next = event.target.value;
if (question.type === "single") {
if (!next.trim()) {
root.clearAnswer(question.id);
return;
}
root.selectSingle(question.id, QUESTION_OTHER_VALUE, next, {
autoAdvance: false,
});
return;
}
root.setMultipleOther(question.id, next);
if (next.trim() && !isOtherSelected) {
root.toggleMultiple(question.id, QUESTION_OTHER_VALUE);
}
};
if (question.type === "multiple") {
return (
<label
data-slot="question-other"
className={cn(questionRowClassName, "cursor-text", className)}
>
<Checkbox
checked={isOtherSelected}
onCheckedChange={handleOtherToggle}
className="mx-1.25 size-4.5 shadow-none transition-colors group-hover/row:data-[state=unchecked]:border-ring/50"
/>
<input
type="text"
value={otherValue}
placeholder={placeholder}
onChange={handleChange}
onKeyDown={onKeyDown}
className="text-primary h-full min-w-0 flex-1 truncate text-sm transition-all outline-none placeholder:text-ring/70"
{...props}
/>
</label>
);
}
return (
<div
data-slot="question-other"
className={cn(
questionRowClassName,
"cursor-text",
isOtherSelected && "bg-muted",
className,
)}
>
<span
className={cn(
"relative flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md bg-border/30 transition-all group-hover/row:bg-border/70",
isOtherSelected && "bg-border/70",
)}
>
<HugeiconsIcon
icon={Edit03Icon}
strokeWidth={2.0}
className={cn(
"size-4 text-muted-foreground transition-all group-hover/row:text-primary",
isOtherSelected && "text-primary",
)}
/>
</span>
<input
type="text"
value={otherValue}
placeholder={placeholder}
onChange={handleChange}
onKeyDown={onKeyDown}
className={cn(
"text-primary h-full min-w-0 flex-1 truncate text-sm transition-all outline-none placeholder:text-ring",
isOtherSelected && "text-primary",
)}
{...props}
/>
</div>
);
}
export type QuestionsTitleProps = React.ComponentProps<typeof CardTitle>;
function QuestionsTitle({ className, children, ...props }: QuestionsTitleProps) {
const root = useQuestionsRoot("QuestionsTitle");
const current = root.questions[root.index];
return (
<CardTitle
data-slot="questions-title"
className={cn("flex-1 text-sm font-normal", className)}
{...props}
>
{children ?? current?.prompt}
</CardTitle>
);
}
export type QuestionsDismissProps = React.ComponentProps<typeof Button>;
function QuestionsDismiss({ className, onClick, ...props }: QuestionsDismissProps) {
const root = useQuestionsRoot("QuestionsDismiss");
return (
<Button
type="button"
size="icon-xs"
variant="ghost"
data-slot="questions-dismiss"
className={cn(
"cursor-pointer rounded-full bg-transparent text-[13px] text-muted-foreground backdrop-blur-lg hover:bg-secondary/80 active:scale-97",
className,
)}
onClick={(event) => {
onClick?.(event);
root.onDismiss?.();
}}
{...props}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2.0} className="size-3.5" />
</Button>
);
}
export type QuestionsHeaderProps = React.ComponentProps<typeof CardHeader>;
function QuestionsHeader({ className, ...props }: QuestionsHeaderProps) {
return (
<CardHeader
data-slot="questions-header"
className={cn(
"flex w-full items-center justify-center gap-2.5 pr-3 pb-1.5 pl-4",
className,
)}
{...props}
/>
);
}
export type QuestionsFooterProps = React.ComponentProps<typeof CardFooter>;
function QuestionsFooter({ className, ...props }: QuestionsFooterProps) {
return (
<CardFooter
data-slot="questions-footer"
className={cn(
"justify-end gap-2 border-none bg-transparent px-3 pt-0 pb-3",
className,
)}
{...props}
/>
);
}
export type QuestionsSkipProps = React.ComponentProps<typeof Button>;
function QuestionsSkip({ className, disabled, onClick, ...props }: QuestionsSkipProps) {
const { questions, index, answers, skip } = useQuestionsRoot("QuestionsSkip");
const current = questions[index];
const canSkip =
index < questions.length - 1 &&
Boolean(current) &&
!isBlockedByRequired(current, answers);
return (
<Button
type="button"
variant="ghost"
size="sm"
data-slot="questions-skip"
disabled={disabled ?? !canSkip}
className={cn(
"text-muted-foreground hover:text-primary active:scale-99",
className,
)}
onClick={(event) => {
onClick?.(event);
skip();
}}
{...props}
>
Skip
</Button>
);
}
export type QuestionsSubmitProps = React.ComponentProps<typeof Button> & {
showOnLastQuestion?: boolean;
disableUntilLastQuestion?: boolean;
};
function QuestionsSubmit({
className,
disabled,
onClick,
children = "Submit",
showOnLastQuestion = false,
disableUntilLastQuestion = false,
...props
}: QuestionsSubmitProps) {
const { questions, index, answers, submit } = useQuestionsRoot("QuestionsSubmit");
const canSubmit = canSubmitQuestions(questions, answers);
const onLastQuestion = index >= questions.length - 1;
if (showOnLastQuestion && !onLastQuestion) {
return null;
}
const isDisabled =
disabled ??
(!canSubmit || (disableUntilLastQuestion && !onLastQuestion));
return (
<Button
type="button"
variant="default"
size="sm"
data-slot="questions-submit"
disabled={isDisabled}
className={cn("active:scale-99", className)}
onClick={(event) => {
onClick?.(event);
submit();
}}
{...props}
>
{children}
</Button>
);
}
function useCarouselViewportHeight(
wrapRef: React.RefObject<HTMLDivElement | null>,
carouselApi: CarouselApi | null,
activeIndex: number,
) {
React.useLayoutEffect(() => {
const vp = wrapRef.current?.querySelector<HTMLElement>(
"[data-slot=carousel-content]",
);
if (!vp) return;
const clear = () => {
vp.style.height = "";
vp.style.transition = "";
};
if (!carouselApi) return clear();
vp.style.transition = "height 500ms ease-out";
const sync = () => {
const h = carouselApi.slideNodes()[activeIndex]?.offsetHeight ?? 0;
vp.style.height = h > 0 ? `${h}px` : "";
};
sync();
const slide = carouselApi.slideNodes()[activeIndex];
if (!slide) return clear;
const ro = new ResizeObserver(sync);
ro.observe(slide);
return () => {
ro.disconnect();
clear();
};
}, [wrapRef, carouselApi, activeIndex]);
}
export type QuestionsCarouselProps = React.ComponentProps<typeof Carousel>;
function QuestionsCarousel({
setApi: setApiProp,
className,
children,
...props
}: QuestionsCarouselProps) {
const { setCarouselApi } = useQuestionsRoot("QuestionsCarousel");
return (
<Carousel
data-slot="questions-carousel"
className={className}
setApi={(api) => {
setCarouselApi(api);
setApiProp?.(api);
}}
opts={{ watchDrag: false }}
{...props}
>
{children}
</Carousel>
);
}
export type QuestionsCarouselContentProps = React.ComponentProps<
typeof CarouselContent
>;
function QuestionsCarouselContent({
className,
...props
}: QuestionsCarouselContentProps) {
const root = useQuestionsRoot("QuestionsCarouselContent");
const wrapRef = React.useRef<HTMLDivElement>(null);
useCarouselViewportHeight(wrapRef, root.carouselApi, root.index);
return (
<div ref={wrapRef} className="contents">
<CarouselContent
data-slot="questions-carousel-content"
className={className}
{...props}
/>
</div>
);
}
export type QuestionsCarouselItemProps = React.ComponentProps<
typeof CarouselItem
>;
function QuestionsCarouselItem({
className,
children,
...props
}: QuestionsCarouselItemProps) {
return (
<CarouselItem
data-slot="questions-carousel-item"
className={cn("w-full self-start p-0 pl-0", className)}
{...props}
>
{children}
</CarouselItem>
);
}
export type QuestionsCarouselPaginationProps = React.HTMLAttributes<HTMLDivElement>;
function QuestionsCarouselPagination({
className,
...props
}: QuestionsCarouselPaginationProps) {
return (
<div
data-slot="questions-carousel-pagination"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
);
}
const carouselNavClassName =
"flex size-6 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-0 transition-all hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 active:scale-97 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-accent/50";
export type QuestionsCarouselNavButtonProps =
React.ButtonHTMLAttributes<HTMLButtonElement>;
function QuestionsCarouselPrev({
className,
children,
...props
}: QuestionsCarouselNavButtonProps) {
const { index, goPrev } = useQuestionsRoot("QuestionsCarouselPrev");
return (
<button
type="button"
data-slot="questions-carousel-prev"
disabled={index <= 0}
className={cn(carouselNavClassName, className)}
onClick={() => goPrev()}
{...props}
>
{children ?? (
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} className="size-4" />
)}
</button>
);
}
function QuestionsCarouselNext({
className,
children,
...props
}: QuestionsCarouselNavButtonProps) {
const { questions, index, answers, goNext } =
useQuestionsRoot("QuestionsCarouselNext");
const current = questions[index];
const canGoNext =
index < questions.length - 1 &&
Boolean(current) &&
!isBlockedByRequired(current, answers);
return (
<button
type="button"
data-slot="questions-carousel-next"
disabled={!canGoNext}
className={cn(carouselNavClassName, className)}
onClick={() => goNext()}
{...props}
>
{children ?? (
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} className="size-4" />
)}
</button>
);
}
export type QuestionsCarouselIndexProps = React.HTMLAttributes<HTMLSpanElement> & {
format?: "of" | "slash";
};
function QuestionsCarouselIndex({
className,
format = "of",
...props
}: QuestionsCarouselIndexProps) {
const { questions, index } = useQuestionsRoot("QuestionsCarouselIndex");
const count = questions.length;
const current = count === 0 ? 0 : index + 1;
return (
<span
data-slot="questions-carousel-index"
className={cn(
"text-xs leading-4.5 font-[350] text-muted-foreground tabular-nums",
className,
)}
{...props}
>
{format === "slash" ? `${current}/${count}` : `${current} of ${count}`}
</span>
);
}
export {
Questions,
Question,
QuestionOptions,
QuestionOption,
QuestionOther,
QuestionsTitle,
QuestionsDismiss,
QuestionsHeader,
QuestionsFooter,
QuestionsSkip,
QuestionsSubmit,
QuestionsCarousel,
QuestionsCarouselContent,
QuestionsCarouselItem,
QuestionsCarouselPagination,
QuestionsCarouselPrev,
QuestionsCarouselNext,
QuestionsCarouselIndex,
};
Update import paths to match your project setup.
Usage
import {
Question,
QuestionOption,
QuestionOptions,
QuestionOther,
Questions,
QuestionsFooter,
QuestionsHeader,
QuestionsSubmit,
QuestionsTitle,
type QuestionInput,
} from "@/components/nexus-ui/questions";const question: QuestionInput = {
id: "depth",
type: "single",
prompt: "How detailed should the explanation be?",
options: [
{ value: "brief", label: "Brief overview" },
{ value: "standard", label: "Standard depth" },
],
};
<Questions
items={[question]}
onSubmit={(submission) => console.log(submission)}
>
<QuestionsHeader>
<QuestionsTitle />
</QuestionsHeader>
<Question id={question.id}>
<QuestionOptions>
{question.options.map((option) => (
<QuestionOption key={option.value} value={option.value}>
{option.label}
</QuestionOption>
))}
<QuestionOther />
</QuestionOptions>
</Question>
<QuestionsFooter>
<QuestionsSubmit />
</QuestionsFooter>
</Questions>Pass items so QuestionsTitle and carousel pagination have question metadata on the first paint. For a single question, compose Question directly without QuestionsCarousel; use the carousel primitives when there are multiple slides.
Examples
Multiple choice
Set type: "multiple" in items to render checkbox rows. Users select one or more options before submitting—multiple choice does not auto-advance.
"use client";
import {
Question,
QuestionOption,
QuestionOptions,
QuestionOther,
type QuestionInput,
Questions,
QuestionsDismiss,
QuestionsFooter,
QuestionsHeader,
QuestionsSubmit,
QuestionsTitle,
} from "@/components/nexus-ui/questions";
import { Toaster } from "@/components/nexus-ui/toaster";
import { toastSubmission } from "@/components/nexus-ui/examples/questions/submission-toast";
const TOASTER_ID = "questions-multiple-choice";
const QUESTION: QuestionInput = {
id: "topics",
type: "multiple",
prompt: "Which topics should I cover in the answer?",
options: [
{ value: "setup", label: "Project setup" },
{ value: "routing", label: "Routing and layouts" },
{ value: "data", label: "Data fetching" },
{ value: "deployment", label: "Deployment" },
],
};
function QuestionsMultipleChoice() {
return (
<div className="w-full">
<Questions
items={[QUESTION]}
onSubmit={(submission) => toastSubmission(submission, TOASTER_ID)}
>
<QuestionsHeader>
<QuestionsTitle />
<QuestionsDismiss />
</QuestionsHeader>
<Question id={QUESTION.id}>
<QuestionOptions>
{QUESTION.options.map((option) => (
<QuestionOption key={option.value} value={option.value}>
{option.label}
</QuestionOption>
))}
<QuestionOther placeholder="Something else..." />
</QuestionOptions>
</Question>
<QuestionsFooter>
<QuestionsSubmit />
</QuestionsFooter>
</Questions>
<Toaster id={TOASTER_ID} />
</div>
);
}
export default QuestionsMultipleChoice;
Multiple questions
For two or more prompts, wrap slides in QuestionsCarousel. Prev/next controls and pagination stay in sync with the active index; required questions block forward navigation until answered. Use disableUntilLastQuestion on QuestionsSubmit to keep submit visible but disabled until the final slide (and required questions are answered).
"use client";
import {
Question,
QuestionOption,
QuestionOptions,
QuestionOther,
type QuestionInput,
Questions,
QuestionsCarousel,
QuestionsCarouselContent,
QuestionsCarouselIndex,
QuestionsCarouselItem,
QuestionsCarouselNext,
QuestionsCarouselPagination,
QuestionsCarouselPrev,
QuestionsDismiss,
QuestionsFooter,
QuestionsHeader,
QuestionsSkip,
QuestionsSubmit,
QuestionsTitle,
} from "@/components/nexus-ui/questions";
import { Toaster } from "@/components/nexus-ui/toaster";
import { toastSubmission } from "@/components/nexus-ui/examples/questions/submission-toast";
const TOASTER_ID = "questions-multiple-questions";
const QUESTIONS: QuestionInput[] = [
{
id: "trip-type",
type: "single",
prompt: "What kind of trip are you planning?",
options: [
{ value: "leisure", label: "Leisure" },
{ value: "business", label: "Business" },
{ value: "family", label: "Family visit" },
],
},
{
id: "budget",
type: "single",
prompt: "What's your nightly budget?",
options: [
{ value: "budget", label: "Under $150" },
{ value: "mid", label: "$150–$300" },
{ value: "luxury", label: "$300+" },
],
},
{
id: "priorities",
type: "multiple",
prompt: "What matters most for this stay?",
options: [
{ value: "location", label: "Central location" },
{ value: "quiet", label: "Quiet room" },
{ value: "breakfast", label: "Breakfast included" },
{ value: "gym", label: "Gym access" },
],
},
];
function QuestionsMultipleQuestions() {
return (
<div className="w-full">
<Questions
items={QUESTIONS}
onSubmit={(submission) => toastSubmission(submission, TOASTER_ID)}
>
<QuestionsCarousel>
<QuestionsHeader>
<QuestionsTitle />
<QuestionsCarouselPagination>
<QuestionsCarouselPrev />
<QuestionsCarouselIndex format="of" />
<QuestionsCarouselNext />
</QuestionsCarouselPagination>
<QuestionsDismiss />
</QuestionsHeader>
<QuestionsCarouselContent className="mx-0">
{QUESTIONS.map((question) => (
<QuestionsCarouselItem key={question.id}>
<Question id={question.id}>
<QuestionOptions>
{question.options.map((option) => (
<QuestionOption key={option.value} value={option.value}>
{option.label}
</QuestionOption>
))}
<QuestionOther />
</QuestionOptions>
</Question>
</QuestionsCarouselItem>
))}
</QuestionsCarouselContent>
</QuestionsCarousel>
<QuestionsFooter>
<QuestionsSkip />
<QuestionsSubmit disableUntilLastQuestion />
</QuestionsFooter>
</Questions>
<Toaster id={TOASTER_ID} />
</div>
);
}
export default QuestionsMultipleQuestions;
Required questions
Set required: true on every question in items to block forward navigation until each slide is answered. Omit QuestionsSkip when nothing is optional—skip only applies to unanswered optional questions. Omit QuestionsDismiss (and onDismiss) when you want a forced flow.
"use client";
import {
Question,
QuestionOption,
QuestionOptions,
QuestionOther,
type QuestionInput,
Questions,
QuestionsCarousel,
QuestionsCarouselContent,
QuestionsCarouselIndex,
QuestionsCarouselItem,
QuestionsCarouselNext,
QuestionsCarouselPagination,
QuestionsCarouselPrev,
QuestionsFooter,
QuestionsHeader,
QuestionsSubmit,
QuestionsTitle,
} from "@/components/nexus-ui/questions";
import { Toaster } from "@/components/nexus-ui/toaster";
import { toastSubmission } from "@/components/nexus-ui/examples/questions/submission-toast";
const TOASTER_ID = "questions-required";
const QUESTIONS: QuestionInput[] = [
{
id: "role",
type: "single",
prompt: "What is your role on the project?",
required: true,
options: [
{ value: "engineer", label: "Engineer" },
{ value: "designer", label: "Designer" },
{ value: "pm", label: "Product manager" },
],
},
{
id: "timeline",
type: "single",
prompt: "When do you need this shipped?",
required: true,
options: [
{ value: "asap", label: "This week" },
{ value: "month", label: "This month" },
{ value: "later", label: "No rush" },
],
},
];
function QuestionsRequired() {
return (
<div className="w-full">
<Questions
items={QUESTIONS}
onSubmit={(submission) => toastSubmission(submission, TOASTER_ID)}
>
<QuestionsCarousel>
<QuestionsHeader>
<QuestionsTitle />
<QuestionsCarouselPagination>
<QuestionsCarouselPrev />
<QuestionsCarouselIndex format="of" />
<QuestionsCarouselNext />
</QuestionsCarouselPagination>
</QuestionsHeader>
<QuestionsCarouselContent className="mx-0">
{QUESTIONS.map((question) => (
<QuestionsCarouselItem key={question.id}>
<Question id={question.id}>
<QuestionOptions>
{question.options.map((option) => (
<QuestionOption key={option.value} value={option.value}>
{option.label}
</QuestionOption>
))}
{question.id !== "role" ? <QuestionOther /> : null}
</QuestionOptions>
</Question>
</QuestionsCarouselItem>
))}
</QuestionsCarouselContent>
</QuestionsCarousel>
<QuestionsFooter>
<QuestionsSubmit disableUntilLastQuestion />
</QuestionsFooter>
</Questions>
<Toaster id={TOASTER_ID} />
</div>
);
}
export default QuestionsRequired;
Fixed options
Omit QuestionOther when the question should only accept predefined options.
"use client";
import {
Question,
QuestionOption,
QuestionOptions,
type QuestionInput,
Questions,
QuestionsFooter,
QuestionsHeader,
QuestionsSubmit,
QuestionsTitle,
} from "@/components/nexus-ui/questions";
import { Toaster } from "@/components/nexus-ui/toaster";
import { toastSubmission } from "@/components/nexus-ui/examples/questions/submission-toast";
const TOASTER_ID = "questions-fixed-options";
const QUESTION: QuestionInput = {
id: "tone",
type: "single",
prompt: "What tone should I use in the reply?",
options: [
{ value: "professional", label: "Professional" },
{ value: "friendly", label: "Friendly" },
{ value: "concise", label: "Concise" },
],
};
function QuestionsFixedOptions() {
return (
<div className="w-full">
<Questions
items={[QUESTION]}
onSubmit={(submission) => toastSubmission(submission, TOASTER_ID)}
>
<QuestionsHeader>
<QuestionsTitle />
</QuestionsHeader>
<Question id={QUESTION.id}>
<QuestionOptions>
{QUESTION.options.map((option) => (
<QuestionOption key={option.value} value={option.value}>
{option.label}
</QuestionOption>
))}
</QuestionOptions>
</Question>
<QuestionsFooter>
<QuestionsSubmit />
</QuestionsFooter>
</Questions>
<Toaster id={TOASTER_ID} />
</div>
);
}
export default QuestionsFixedOptions;
Vercel AI SDK Integration
Use Questions when an agent needs structured clarification before continuing—for example a askClarifyingQuestions tool that streams arguments to the client and waits for the user's answers.
The usual pattern:
- Define a tool without
executeso the model stops after emitting question definitions. - Render
Questionswhen the tool part reachesinput-available. - Call
addToolOutput(oraddToolResult) inonSubmitwith the submission payload. - Optionally use
sendAutomaticallyWhenso the chat resumes after the tool output is added.
This flow is client-side tool handling. The model proposes questions; your UI collects answers and returns them as the tool result so the agent can continue.
Install the AI SDK
npm install ai @ai-sdk/react zodExpose a clarification tool on your chat route
Define a tool with a Zod schema that matches QuestionInput. Omit execute so the client must supply the result.
import { convertToModelMessages, streamText, tool, type UIMessage } from "ai";
import { z } from "zod";
const questionSchema = z.object({
id: z.string(),
type: z.enum(["single", "multiple"]),
prompt: z.string(),
required: z.boolean().optional(),
options: z.array(
z.object({
value: z.string(),
label: z.string(),
}),
),
});
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: "anthropic/claude-sonnet-4.5",
system:
"When the user's request is ambiguous, call askClarifyingQuestions before answering.",
messages: await convertToModelMessages(messages),
tools: {
askClarifyingQuestions: tool({
description:
"Ask the user one or more clarifying questions before continuing.",
inputSchema: z.object({
questions: z.array(questionSchema).min(1),
}),
}),
},
});
return result.toUIMessageStreamResponse();
}Render tool input as Questions and return answers with addToolOutput
Find the latest assistant message with an askClarifyingQuestions tool part in input-available state, map part.input.questions to items, and wire onSubmit to addToolOutput. Use sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls (or your own predicate) so the model continues after answers are submitted.
"use client";
import { useChat } from "@ai-sdk/react";
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithToolCalls,
type UIMessage,
} from "ai";
import {
Question,
QuestionOption,
QuestionOptions,
QuestionOther,
Questions,
QuestionsCarousel,
QuestionsCarouselContent,
QuestionsCarouselIndex,
QuestionsCarouselItem,
QuestionsCarouselNext,
QuestionsCarouselPagination,
QuestionsCarouselPrev,
QuestionsFooter,
QuestionsHeader,
QuestionsSkip,
QuestionsSubmit,
QuestionsTitle,
type QuestionInput,
type QuestionsSubmission,
} from "@/components/nexus-ui/questions";
type ClarifyingQuestionsInput = {
questions: QuestionInput[];
};
type ClarifyingQuestionsToolPart = {
type: "tool-askClarifyingQuestions";
toolCallId: string;
state: "input-available" | "output-available" | "output-error";
input?: ClarifyingQuestionsInput;
};
function findPendingClarification(messages: UIMessage[]) {
const assistant = [...messages].reverse().find((m) => m.role === "assistant");
if (!assistant) return null;
return assistant.parts.find(
(part): part is ClarifyingQuestionsToolPart =>
part.type === "tool-askClarifyingQuestions" &&
part.state === "input-available" &&
Boolean((part as ClarifyingQuestionsToolPart).input?.questions?.length),
);
}
export default function ClarificationChatPage() {
const { messages, addToolOutput, status } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
});
const pending = findPendingClarification(messages);
if (!pending?.input?.questions) return null;
const items = pending.input.questions;
function handleSubmit(submission: QuestionsSubmission) {
void addToolOutput({
tool: "askClarifyingQuestions",
toolCallId: pending.toolCallId,
output: { answers: submission },
});
}
return (
<Questions items={items} onSubmit={handleSubmit}>
<QuestionsCarousel>
<QuestionsHeader>
<QuestionsTitle />
{items.length > 1 ? (
<QuestionsCarouselPagination>
<QuestionsCarouselPrev />
<QuestionsCarouselIndex format="of" />
<QuestionsCarouselNext />
</QuestionsCarouselPagination>
) : null}
</QuestionsHeader>
<QuestionsCarouselContent className="mx-0">
{items.map((question) => (
<QuestionsCarouselItem key={question.id}>
<Question id={question.id}>
<QuestionOptions>
{question.options.map((option) => (
<QuestionOption key={option.value} value={option.value}>
{option.label}
</QuestionOption>
))}
<QuestionOther />
</QuestionOptions>
</Question>
</QuestionsCarouselItem>
))}
</QuestionsCarouselContent>
</QuestionsCarousel>
<QuestionsFooter>
{items.length > 1 ? <QuestionsSkip /> : null}
<QuestionsSubmit showOnLastQuestion disabled={status !== "ready"} />
</QuestionsFooter>
</Questions>
);
}Alternative: send answers as a user message
If you are not using tools, format QuestionsSubmission and call sendMessage directly—useful for lightweight clarification UIs that do not participate in the tool loop.
import type { QuestionsSubmission } from "@/components/nexus-ui/questions";
function formatAnswers(submission: QuestionsSubmission) {
return submission
.map((entry) => {
if (entry.type === "single") {
return `- ${entry.prompt}: ${entry.answer.label}`;
}
const labels = entry.answer.map((item) => item.label).join(", ");
return `- ${entry.prompt}: ${labels}`;
})
.join("\n");
}
// onSubmit={(submission) => sendMessage({ text: formatAnswers(submission) })}API Reference
Questions
Root card and state container. Manages answer state, active index, carousel sync, skip/submit rules, and typed submission payloads. Extends shadcn Card.
Prop
Type
QuestionsHeader
Top bar layout shell for title, pagination, and dismiss controls. Extends shadcn CardHeader.
Prop
Type
QuestionsTitle
Renders the active question prompt from items[root.index] in CardTitle, unless children override it. Extends shadcn CardTitle.
Prop
Type
QuestionsDismiss
Icon dismiss control. Invokes onDismiss on the root. Extends shadcn Button.
Prop
Type
QuestionsCarousel
Carousel root for multi-question slides. Registers the Embla API on Questions for index sync and animated viewport height.
Prop
Type
QuestionsCarouselPagination
Flex row for prev / index / next controls.
Prop
Type
QuestionsCarouselPrev
Previous-slide button. Disabled on the first question.
Prop
Type
QuestionsCarouselIndex
Active slide indicator (1-based current). Renders a span.
Prop
Type
QuestionsCarouselNext
Next-slide button. Disabled on the last question or when the current required question is unanswered.
Prop
Type
QuestionsCarouselContent
Carousel track wrapper. Animates viewport height to the active slide. Extends shadcn CarouselContent.
Prop
Type
QuestionsCarouselItem
One carousel slide. Wrap Question inside.
Prop
Type
Question
One question scope for option components. Wraps children in CardContent. id must match an entry in Questions items.
Prop
Type
QuestionOptions
Vertical list wrapper for QuestionOption and QuestionOther. Injects optionIndex into QuestionOption children for numbered single-choice badges.
Prop
Type
QuestionOption
Selectable row. Single choice renders a numbered button; clicks advance to the next slide when Questions autoAdvance is enabled (default). Multiple choice renders a checkbox label.
Prop
Type
QuestionOther
Free-text Other row. Compose only when free-text answers are allowed. Uses QUESTION_OTHER_VALUE ("__other__") as the stored value when selected. Typing selects Other (single and multiple); multiple choice also pairs with a checkbox.
Prop
Type
QuestionsFooter
Bottom bar layout shell for skip and submit actions. Extends shadcn CardFooter.
Prop
Type
QuestionsSkip
Ghost Skip button with a fixed Skip label. Disabled on the last question, when the current question is required and unanswered, or when there is no current question. Extends shadcn Button.
Prop
Type
QuestionsSubmit
Primary Submit button. Disabled while any required question is unanswered; optional unanswered questions are allowed. Extends shadcn Button.
Prop
Type
Types
export type QuestionOptionInput = {
value: string;
label: React.ReactNode;
};
export type QuestionInput = {
id: string;
type: "single" | "multiple";
prompt: React.ReactNode;
options: QuestionOptionInput[];
required?: boolean;
};
export type QuestionSubmissionAnswer = {
value: string;
label: React.ReactNode;
};
export type QuestionsSubmission = Array<
| {
questionId: string;
prompt: React.ReactNode;
type: "single";
status: "answered";
answer: QuestionSubmissionAnswer;
}
| {
questionId: string;
prompt: React.ReactNode;
type: "single";
status: "skipped";
answer: QuestionSubmissionAnswer;
}
| {
questionId: string;
prompt: React.ReactNode;
type: "multiple";
status: "answered";
answer: QuestionSubmissionAnswer[];
}
| {
questionId: string;
prompt: React.ReactNode;
type: "multiple";
status: "skipped";
answer: QuestionSubmissionAnswer[];
}
>;
export const QUESTION_OTHER_VALUE = "__other__";
export const QUESTION_NO_PREFERENCE_VALUE = "__no_preference__";
export const QUESTION_NO_PREFERENCE_LABEL = "[No Preference]";