shadimage
blode
uiask-user-questionssource: ok · refreshed

Ask User Questions

A stepped questionnaire with single- or multi-select options, free-form answers, and skip.

npx shadcn add "https://blode.co/ui/r/ask-user-questions.json"
free

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Registry dependencies

buttonbadgetextarea

Files1 · source cached 10/8/2026, 7:03:16 AM

ui/ask-user-questions.tsx
"use client";

import { ArrowLeftIcon, ArrowRightIcon } from "blode-icons-react";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";

const springs = {
  fast: { bounce: 0, duration: 0.08, type: "spring" as const },
  moderate: { bounce: 0.15, duration: 0.16, type: "spring" as const },
  slow: { bounce: 0.15, duration: 0.24, type: "spring" as const },
};

interface AskUserOption {
  id: string;
  /** Bold option label. */
  title: string;
  /** Secondary descriptive text. */
  description?: string;
}

interface AskUserQuestion {
  id: string;
  /** Question text. */
  title: string;
  options: AskUserOption[];
  /** Allow multiple selections (shows an explicit Next/Finish button). */
  multiSelect?: boolean;
  /** Reveal a textarea for a free-form answer. */
  allowOther?: boolean;
  otherPlaceholder?: string;
  /** Label for the Next button in multi-select mode. Defaults to "Next" / "Finish". */
  nextLabel?: string;
  /** Show the Skip control. Defaults to true. */
  skippable?: boolean;
  /** Description placement: inline with the title or stacked beneath it. */
  layout?: "inline" | "stacked";
}

interface AskUserAnswer {
  questionId: string;
  selectedIds: string[];
  otherText?: string;
  skipped?: boolean;
}

type AnswersMap = Record<string, AskUserAnswer>;

interface AskUserQuestionsProps extends Omit<React.ComponentProps<"div">, "onChange"> {
  questions: AskUserQuestion[];
  currentIndex?: number;
  defaultCurrentIndex?: number;
  onCurrentIndexChange?: (index: number) => void;
  answers?: AnswersMap;
  defaultAnswers?: AnswersMap;
  onAnswersChange?: (answers: AnswersMap) => void;
  onComplete?: (answers: AnswersMap) => void;
  onSkip?: (questionId: string, index: number) => void;
  skipLabel?: string;
}

const useControllable = <T,>(
  controlled: T | undefined,
  defaultValue: T,
  onChange?: (value: T) => void,
): [T, (value: T) => void] => {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const isControlled = controlled !== undefined;
  const value = isControlled ? (controlled as T) : uncontrolled;
  const setValue = React.useCallback(
    (next: T) => {
      if (!isControlled) {
        setUncontrolled(next);
      }
      onChange?.(next);
    },
    [isControlled, onChange],
  );
  return [value, setValue];
};

const getChipClassName = (isMulti: boolean, selected: boolean) => {
  if (isMulti) {
    return cn(
      "rounded-full",
      selected
        ? "bg-foreground font-semibold text-background"
        : "border border-border text-muted-foreground",
    );
  }
  return selected ? "font-semibold text-foreground" : "text-muted-foreground";
};

interface AskUserOptionButtonProps {
  option: AskUserOption;
  index: number;
  selected: boolean;
  hovered: boolean;
  isMulti: boolean;
  layout: "inline" | "stacked";
  onSelect: () => void;
  onHover: () => void;
}

const AskUserOptionButton = ({
  option,
  index,
  selected,
  hovered,
  isMulti,
  layout,
  onSelect,
  onHover,
}: AskUserOptionButtonProps) => {
  const showArrow = !isMulti && hovered;
  return (
    <button
      aria-checked={selected}
      className={cn(
        "relative z-10 flex items-center gap-3 rounded-full px-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#6B97FF]",
        layout === "stacked" ? "min-h-14 py-2" : "min-h-10 py-1.5",
      )}
      onClick={onSelect}
      onMouseEnter={onHover}
      role={isMulti ? "checkbox" : "radio"}
      type="button"
    >
      {hovered && !selected && (
        <motion.span
          aria-hidden="true"
          className="absolute inset-0 -z-10 rounded-full bg-muted"
          layoutId="ask-user-hover"
          transition={springs.fast}
        />
      )}
      {selected &&
        (isMulti ? (
          <span aria-hidden="true" className="absolute inset-0 -z-10 rounded-full bg-accent" />
        ) : (
          <motion.span
            aria-hidden="true"
            className="absolute inset-0 -z-10 rounded-full bg-accent"
            layoutId="ask-user-selected"
            transition={springs.moderate}
          />
        ))}

      <span
        className={cn(
          "min-w-0 flex-1 text-[13px] leading-snug",
          layout === "stacked" ? "flex flex-col gap-0.5" : "",
        )}
      >
        <span className={cn("text-foreground", selected ? "font-semibold" : "font-medium")}>
          {option.title}
        </span>
        {option.description &&
          (layout === "stacked" ? (
            <span className="text-muted-foreground text-xs leading-snug">{option.description}</span>
          ) : (
            <span className="text-muted-foreground"> {option.description}</span>
          ))}
      </span>

      <span className="relative inline-flex size-7 shrink-0 items-center justify-center">
        <span
          className={cn(
            "inline-flex size-5 items-center justify-center text-[11px] transition-opacity",
            getChipClassName(isMulti, selected),
            showArrow && "opacity-0",
          )}
        >
          {index + 1}
        </span>
        <AnimatePresence>
          {showArrow && (
            <motion.span
              animate={{ opacity: 1, scale: 1 }}
              aria-hidden="true"
              className="absolute inset-0 inline-flex items-center justify-center rounded-full bg-foreground text-background"
              exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.06 } }}
              initial={{ opacity: 0, scale: 0.6 }}
              transition={springs.fast}
            >
              <ArrowRightIcon className="size-3.5" />
            </motion.span>
          )}
        </AnimatePresence>
      </span>
    </button>
  );
};

interface AskUserOtherInputProps {
  placeholder?: string;
  optionCount: number;
  otherText: string;
  isMulti: boolean;
  onChangeText: (text: string) => void;
  onSubmit: () => void;
}

const AskUserOtherInput = ({
  placeholder,
  optionCount,
  otherText,
  isMulti,
  onChangeText,
  onSubmit,
}: AskUserOtherInputProps) => (
  <div
    className={cn(
      "relative z-10 flex items-start gap-3 rounded-full px-3 py-2",
      otherText.length > 0 && "bg-accent",
    )}
  >
    <textarea
      aria-label={placeholder ?? "Describe in your own words"}
      className="min-h-7 flex-1 resize-none self-center bg-transparent text-[13px] text-foreground leading-snug outline-none [field-sizing:content] placeholder:text-muted-foreground"
      onChange={(e) => onChangeText(e.target.value)}
      onKeyDown={(e) => {
        if (e.key === "Enter" && !e.shiftKey && !isMulti && otherText.trim()) {
          e.preventDefault();
          onSubmit();
        }
      }}
      placeholder={placeholder ?? "Describe in your own words…"}
      rows={1}
      value={otherText}
    />
    <span
      className={cn(
        "inline-flex size-5 shrink-0 items-center justify-center self-center text-[11px]",
        otherText.length > 0 ? "font-semibold text-foreground" : "text-muted-foreground",
      )}
    >
      {optionCount + 1}
    </span>
  </div>
);

interface AskUserFooterProps {
  showBack: boolean;
  showSkip: boolean;
  showConfirm: boolean;
  skipLabel: string;
  nextLabel?: string;
  isLast: boolean;
  isMulti: boolean;
  canProceed: boolean;
  onBack: () => void;
  onSkip: () => void;
  onNext: () => void;
}

const AskUserFooter = ({
  showBack,
  showSkip,
  showConfirm,
  skipLabel,
  nextLabel,
  isLast,
  isMulti,
  canProceed,
  onBack,
  onSkip,
  onNext,
}: AskUserFooterProps) => (
  <div className="flex items-center justify-between gap-2 px-4 pt-1 pb-3 sm:px-5">
    <div>
      {showBack && (
        <Button data-icon="inline-start" onClick={onBack} size="sm" variant="ghost">
          <ArrowLeftIcon className="hidden sm:block" />
          Back
        </Button>
      )}
    </div>
    <div className="flex items-center gap-2">
      {showSkip && (
        <Button data-icon="inline-end" onClick={onSkip} size="sm" variant="ghost">
          {skipLabel}
          <ArrowRightIcon className="hidden sm:block" />
        </Button>
      )}
      {showConfirm && (
        <Button disabled={!canProceed} onClick={onNext} size="sm">
          {nextLabel ?? (isLast ? "Finish" : "Next")}
          {isMulti && (
            <kbd className="-mr-0.5 ml-0.5 inline-flex items-center gap-0.5 rounded-full bg-primary-foreground/15 px-1.5 py-px font-medium text-[10px] text-primary-foreground/90">
              ⌘↵
            </kbd>
          )}
        </Button>
      )}
    </div>
  </div>
);

// ─── AskUserQuestions ───────────────────────────────────────────────────────
// A stepped questionnaire rendered as a single card: single- or multi-select
// options with an optional free-form answer and skip control. A morphing
// background slides between options on hover and selection. Single-select
// questions advance on click (an arrow overlays the number chip); multi-select
// (or "other"-enabled) questions advance via the footer Next / Finish button.
const AskUserQuestions = ({
  questions,
  currentIndex,
  defaultCurrentIndex = 0,
  onCurrentIndexChange,
  answers,
  defaultAnswers,
  onAnswersChange,
  onComplete,
  onSkip,
  skipLabel = "Skip",
  className,
  ref,
  ...props
}: AskUserQuestionsProps) => {
  const [index, setIndex] = useControllable(
    currentIndex,
    defaultCurrentIndex,
    onCurrentIndexChange,
  );
  const [answersMap, setAnswersMap] = useControllable(
    answers,
    defaultAnswers ?? {},
    onAnswersChange,
  );
  const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);

  const current = questions[index];

  const currentAnswer: AskUserAnswer = React.useMemo(
    () =>
      current
        ? (answersMap[current.id] ?? { questionId: current.id, selectedIds: [] })
        : { questionId: "", selectedIds: [] },
    [current, answersMap],
  );

  const commit = React.useCallback(
    (answer: AskUserAnswer): AnswersMap => {
      const next = { ...answersMap, [answer.questionId]: answer };
      setAnswersMap(next);
      return next;
    },
    [answersMap, setAnswersMap],
  );

  const goNext = React.useCallback(
    (nextAnswers: AnswersMap) => {
      setHoverIndex(null);
      if (index >= questions.length - 1) {
        onComplete?.(nextAnswers);
        return;
      }
      setIndex(index + 1);
    },
    [index, questions.length, onComplete, setIndex],
  );

  const containerRef = React.useRef<HTMLDivElement>(null);
  React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);

  // The Cmd/Ctrl+Enter submit shortcut is attached as a native listener rather
  // than a JSX handler so the container stays a non-interactive grouping element.
  const handleShortcut = React.useCallback(
    (e: KeyboardEvent) => {
      if (!current) {
        return;
      }
      const showConfirm = Boolean(current.multiSelect || current.allowOther);
      const otherText = currentAnswer.otherText ?? "";
      const canProceed = currentAnswer.selectedIds.length > 0 || otherText.trim().length > 0;
      if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && showConfirm && canProceed) {
        e.preventDefault();
        goNext(commit({ ...currentAnswer, skipped: false }));
      }
    },
    [current, currentAnswer, commit, goNext],
  );

  React.useEffect(() => {
    const node = containerRef.current;
    if (!node) {
      return;
    }
    const listener = (e: KeyboardEvent) => handleShortcut(e);
    node.addEventListener("keydown", listener);
    return () => node.removeEventListener("keydown", listener);
  }, [handleShortcut]);

  if (!current) {
    return null;
  }

  const layout = current.layout ?? "inline";
  const skippable = current.skippable !== false;
  const isMulti = Boolean(current.multiSelect);
  const showConfirm = Boolean(current.multiSelect || current.allowOther);
  const isLast = index === questions.length - 1;
  const otherText = currentAnswer.otherText ?? "";
  const canProceed = currentAnswer.selectedIds.length > 0 || otherText.trim().length > 0;
  const showBack = index > 0;
  const showSkip = skippable && questions.length > 1;
  const showFooter = showBack || showSkip || showConfirm;

  const selectOption = (optionId: string) => {
    if (isMulti) {
      const selected = currentAnswer.selectedIds.includes(optionId);
      const selectedIds = selected
        ? currentAnswer.selectedIds.filter((id) => id !== optionId)
        : [...currentAnswer.selectedIds, optionId];
      commit({ ...currentAnswer, selectedIds, skipped: false });
      return;
    }

    const answer: AskUserAnswer = {
      ...currentAnswer,
      selectedIds: [optionId],
      skipped: false,
    };
    const next = commit(answer);
    if (!current.allowOther) {
      goNext(next);
    }
  };

  const setOtherText = (text: string) => {
    commit({ ...currentAnswer, otherText: text, skipped: false });
  };

  const handleNext = () => {
    goNext(commit({ ...currentAnswer, skipped: false }));
  };

  const handleBack = () => {
    if (index > 0) {
      setHoverIndex(null);
      setIndex(index - 1);
    }
  };

  const handleSkip = () => {
    const next = commit({
      otherText: "",
      questionId: current.id,
      selectedIds: [],
      skipped: true,
    });
    onSkip?.(current.id, index);
    goNext(next);
  };

  return (
    <div
      className={cn(
        "relative w-full max-w-[520px] overflow-hidden rounded-2xl border border-border bg-card",
        className,
      )}
      data-slot="ask-user-questions"
      ref={containerRef}
      {...props}
    >
      {/* Header — progress sits at the top, fixed across questions. */}
      <div className="flex items-center px-4 pt-4 pb-2 text-muted-foreground text-xs tabular-figures sm:px-5 sm:pt-5">
        Question {index + 1} of {questions.length}
      </div>

      <AnimatePresence initial={false} mode="wait">
        <motion.div
          animate={{ opacity: 1, y: 0 }}
          className="px-4 pb-1 sm:px-5"
          exit={{ opacity: 0, transition: { duration: 0.1 }, y: -6 }}
          initial={{ opacity: 0, y: 8 }}
          key={current.id}
          layout
          transition={springs.moderate}
        >
          <h3 className="font-semibold text-base text-foreground leading-snug">{current.title}</h3>

          <div
            className="relative mt-2 flex flex-col gap-0.5"
            onMouseLeave={() => setHoverIndex(null)}
            role={isMulti ? "group" : "radiogroup"}
          >
            {current.options.map((option, i) => (
              <AskUserOptionButton
                hovered={hoverIndex === i}
                index={i}
                isMulti={isMulti}
                key={option.id}
                layout={layout}
                onHover={() => setHoverIndex(i)}
                onSelect={() => selectOption(option.id)}
                option={option}
                selected={currentAnswer.selectedIds.includes(option.id)}
              />
            ))}

            {current.allowOther && (
              <AskUserOtherInput
                isMulti={isMulti}
                onChangeText={setOtherText}
                onSubmit={handleNext}
                optionCount={current.options.length}
                otherText={otherText}
                placeholder={current.otherPlaceholder}
              />
            )}
          </div>
        </motion.div>
      </AnimatePresence>

      {showFooter && (
        <AskUserFooter
          canProceed={canProceed}
          isLast={isLast}
          isMulti={isMulti}
          nextLabel={current.nextLabel}
          onBack={handleBack}
          onNext={handleNext}
          onSkip={handleSkip}
          showBack={showBack}
          showConfirm={showConfirm}
          showSkip={showSkip}
          skipLabel={skipLabel}
        />
      )}
    </div>
  );
};

export { AskUserQuestions };
export type { AskUserQuestionsProps, AskUserQuestion, AskUserOption, AskUserAnswer };

More from blode

Browse all components in this library →