shadimage
turbopills-ui
componentmultiple-choicesource: ok · hit

Multiple Choice

Multiple choice field with optional max selections.

npx shadcn add "https://ui.turbopills.com/r/multiple-choice.json"
free

Interaction

dropdown

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

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

checkboxbadge

Files3 · source cached 10/14/2026, 8:48:30 PM

registry/turbopills/lib/styles.tsx
import { cn } from "@/lib/utils";

export const optionStyles = {
  base: "h-15 inline-flex items-center justify-between w-full bg-background border rounded-lg p-4 cursor-pointer transition-all",
  textSelectionDisabled: "select-none",
  hover: "hover:ring-1 hover:ring-border",
  outlineOnSelect: "ring-2 ring-primary hover:ring-2 hover:ring-primary",
  fillOnSelect: "bg-primary/10",
  shadowOnSelect: "shadow-md shadow-primary/25",
  disabled: "opacity-50 cursor-not-allowed",
};

export const getOptionClassName = ({
  selected = false,
  highlightOnHover = true,
  showOutlineOnSelect = true,
  showFillOnSelect = false,
  showShadowOnSelect = false,
  disabled = false,
  allowTextSelection = false,
}: {
  selected?: boolean;
  highlightOnHover?: boolean;
  showOutlineOnSelect?: boolean;
  showFillOnSelect?: boolean;
  showShadowOnSelect?: boolean;
  disabled?: boolean;
  allowTextSelection?: boolean;
}) =>
  cn(
    optionStyles.base,
    !allowTextSelection && optionStyles.textSelectionDisabled,
    highlightOnHover && optionStyles.hover,
    selected && showOutlineOnSelect && optionStyles.outlineOnSelect,
    selected && showFillOnSelect && optionStyles.fillOnSelect,
    selected && showShadowOnSelect && optionStyles.shadowOnSelect,
    disabled && optionStyles.disabled,
  );

export const fieldHeaderStyles = {
  container: "mb-3 space-y-1",
  title: "text-3xl leading-9 font-medium",
  titleSm: "text-xl leading-6 font-medium",
  hint: "text-muted-foreground",
  hintSm: "text-sm text-muted-foreground",
};

export const inputStyles = {
  wrapper:
    "relative flex items-center w-full border rounded-2xl bg-background hover:border-primary transition-[color,border-color] overflow-hidden focus-within:ring-1 focus-within:ring-primary focus-within:border-primary",
  wrapperDefault: "h-[60px]",
  wrapperSm: "h-10 !rounded-lg",
  input:
    "flex-1 w-full bg-transparent border-0 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
  inputDefault: "px-5 py-4 text-md placeholder:text-md",
  inputSm:
    "h-[38px] p-2 text-sm font-light placeholder:text-sm placeholder:font-light",
  suffix: "flex items-center pl-2 pr-5 text-muted-foreground text-sm",
  error: "border-destructive focus-within:outline-destructive/20 focus-within:border-destructive",
  autofill:
    "[&:has(input:-webkit-autofill)]:bg-[#e8f0fe]",
  inputAutofill:
    "autofill:[box-shadow:inset_0_0_0_9999px_#e8f0fe] autofill:[-webkit-text-fill-color:var(--color-foreground)]",
};

export const getInputWrapperClassName = ({
  size = "md",
  hasError = false,
}: {
  size?: "md" | "sm";
  hasError?: boolean;
}) =>
  cn(
    inputStyles.wrapper,
    inputStyles.autofill,
    size === "sm" ? inputStyles.wrapperSm : inputStyles.wrapperDefault,
    hasError && inputStyles.error,
  );

export const getInputClassName = ({
  size = "md",
  hasSuffix = false,
}: {
  size?: "md" | "sm";
  hasSuffix?: boolean;
}) =>
  cn(
    inputStyles.input,
    inputStyles.inputAutofill,
    size === "sm" ? inputStyles.inputSm : inputStyles.inputDefault,
    hasSuffix && "pr-0",
  );
registry/turbopills/lib/types.ts
import * as React from "react";

export type ChoiceOption = {
  value: string;
  label: React.ReactNode;
  badge?: string;
  disabled?: boolean;
  none?: boolean;
};
registry/turbopills/ui/multiple-choice.tsx
"use client";

import * as React from "react";
import { CircleCheck } from "lucide-react";

import { cn } from "@/lib/utils";
import { getOptionClassName, fieldHeaderStyles } from "@/lib/styles";
import { ChoiceOption } from "@/lib/types";

import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";

export interface MultipleChoiceProps
  extends Omit<
    React.ComponentProps<typeof Checkbox>,
    "checked" | "onChange" | "value"
  > {
  title?: string;
  hint?: string;
  options: ChoiceOption[];
  value?: string[];
  onChange?: (value: string[]) => void;
  maxSelections?: number;
  className?: string;
  checkboxPosition?: "left" | "right";
  checkboxVariant?: "checkbox" | "icon" | "none";
  showNumbers?: boolean;
  highlightOnHover?: boolean;
  showOutlineOnSelect?: boolean;
  showFillOnSelect?: boolean;
  showShadowOnSelect?: boolean;
  allowTextSelection?: boolean;
}

export function MultipleChoice({
  title,
  hint,
  options,
  value = [],
  onChange,
  maxSelections,
  className,
  disabled,
  checkboxPosition = "right",
  checkboxVariant = "icon",
  showNumbers = false,
  highlightOnHover = true,
  showOutlineOnSelect = true,
  showFillOnSelect = false,
  showShadowOnSelect = false,
  allowTextSelection = false,
  ...checkboxProps
}: MultipleChoiceProps) {
  const groupId = React.useId();

  const noneOption = options.find((opt) => opt.none);

  const isMaxReached =
    maxSelections !== undefined && value.length >= maxSelections;

  const handleCheckedChange = (
    optionValue: string,
    checked: boolean,
    isNoneOption: boolean,
  ) => {
    if (!onChange) return;

    if (isNoneOption) {
      onChange(checked ? [optionValue] : []);
      return;
    }

    if (checked && isMaxReached) return;

    const newValues = checked
      ? [
        ...value.filter((v) => !noneOption || v !== noneOption.value),
        optionValue,
      ]
      : value.filter((v) => v !== optionValue);

    onChange(newValues);
  };

  return (
    <div className={cn("space-y-3", className)}>
      {(title || hint) && (
        <div className={fieldHeaderStyles.container}>
          {title && <div className={fieldHeaderStyles.title}>{title}</div>}
          {hint && <p className={fieldHeaderStyles.hint}>{hint}</p>}
        </div>
      )}

      <div className="space-y-3">
        {options.map((option, index) => {
          const optionId = `${groupId}-${option.value}`;
          const isChecked = value.includes(option.value);
          const isDisabled =
            disabled || option.disabled || (isMaxReached && !isChecked);

          const renderIndicator = () => {
            return (
              <>
                <Checkbox
                  id={optionId}
                  checked={isChecked}
                  onCheckedChange={(checked) =>
                    handleCheckedChange(
                      option.value,
                      checked === true,
                      option.none === true,
                    )
                  }
                  disabled={isDisabled}
                  className={checkboxVariant !== "checkbox" ? "sr-only peer" : ""}
                  {...checkboxProps}
                />
                {checkboxVariant === "icon" && isChecked && (
                  <CircleCheck className="shrink-0 h-5.75 w-5.75 text-primary" />
                )}
              </>
            );
          };

          return (
            <div key={option.value} className="flex items-center">
              <label
                htmlFor={optionId}
                className={getOptionClassName({
                  selected: isChecked,
                  highlightOnHover,
                  showOutlineOnSelect,
                  showFillOnSelect,
                  showShadowOnSelect,
                  disabled: isDisabled,
                  allowTextSelection,
                })}
              >
                <div className="flex items-center gap-3">
                  {showNumbers && (
                    <span className="shrink-0 w-6 h-6 flex items-center justify-center rounded-full bg-muted text-sm font-medium">
                      {index + 1}
                    </span>
                  )}

                  {checkboxPosition !== "right" && renderIndicator()}

                  <div className="flex items-center gap-2">
                    <span>{option.label}</span>
                    {option.badge && (
                      <Badge className="px-2 py-1 text-sm border-none leading-5 whitespace-nowrap">
                        {option.badge}
                      </Badge>
                    )}
                  </div>
                </div>

                {checkboxPosition === "right" && renderIndicator()}
              </label>
            </div>
          );
        })}
      </div>
    </div>
  );
}

More from turbopills-ui

Browse all components in this library →