LLM index: /llms.txt

Questions

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.

How detailed should the explanation be?
"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/questions
pnpm dlx shadcn@latest add @nexus-ui/questions
yarn dlx shadcn@latest add @nexus-ui/questions
bunx shadcn@latest add @nexus-ui/questions

Install the following dependencies:

npx shadcn@latest add carousel button card checkbox && npm install @hugeicons/react @hugeicons/core-free-icons
pnpm dlx shadcn@latest add carousel button card checkbox && pnpm add @hugeicons/react @hugeicons/core-free-icons
yarn dlx shadcn@latest add carousel button card checkbox && yarn add @hugeicons/react @hugeicons/core-free-icons
bunx shadcn@latest add carousel button card checkbox && bun add @hugeicons/react @hugeicons/core-free-icons

Copy and paste the following code into your project.

components/nexus-ui/questions.tsx
"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.

Which topics should I cover in the answer?
"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).

What kind of trip are you planning?
1 of 3
"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.

What is your role on the project?
1 of 2
"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.

What tone should I use in the reply?
"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:

  1. Define a tool without execute so the model stops after emitting question definitions.
  2. Render Questions when the tool part reaches input-available.
  3. Call addToolOutput (or addToolResult) in onSubmit with the submission payload.
  4. Optionally use sendAutomaticallyWhen so 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 zod

Expose 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.

app/api/chat/route.ts
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.

app/clarification-chat/page.tsx
"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]";
View as markdown Edit on GitHub