shadimage
turbopills-ui
componentsingle-choicesource: ok · hit

Single Choice

Single choice field (list or cells) with optional deselect.

npx shadcn add "https://ui.turbopills.com/r/single-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

radio-groupbadge

Files3 · source cached 10/8/2026, 6:51:16 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/single-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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Badge } from "@/components/ui/badge";

export type IndicatorVariant = "radio" | "icon" | "none";

export interface SingleChoiceProps
  extends Omit<React.ComponentProps<typeof RadioGroup>, "value" | "onChange"> {
  title?: string;
  hint?: string;
  options: ChoiceOption[];
  value?: string;
  onChange?: (value: string) => void;
  variant?: "default" | "cells";
  itemsPerRow?: number;
  indicatorPosition?: "left" | "right";
  indicatorVariant?: IndicatorVariant;
  showNumbers?: boolean;
  highlightOnHover?: boolean;
  showOutlineOnSelect?: boolean;
  showFillOnSelect?: boolean;
  showShadowOnSelect?: boolean;
  allowTextSelection?: boolean;
  allowDeselect?: boolean;
  onOptionClick?: (value: string) => void;
}

export function SingleChoice({
  title,
  hint,
  options,
  value,
  onChange,
  variant = "default",
  itemsPerRow,
  indicatorPosition = "right",
  indicatorVariant = "icon",
  showNumbers = false,
  highlightOnHover = true,
  showOutlineOnSelect = true,
  showFillOnSelect = false,
  showShadowOnSelect = false,
  allowTextSelection = false,
  allowDeselect = false,
  onOptionClick,
  className,
  disabled,
  ...radioGroupProps
}: SingleChoiceProps) {
  const groupId = React.useId();

  const isCells = variant === "cells";
  const effectiveIndicatorVariant = isCells ? "none" : indicatorVariant;
  const effectiveItemsPerRow =
    itemsPerRow ?? (isCells ? options.length : undefined);

  const gridColumns = (() => {
    if (!effectiveItemsPerRow) return undefined;
    if (isCells) {
      return "repeat(auto-fit, minmax(2.5rem, 3.75rem))";
    }
    return `repeat(${effectiveItemsPerRow}, 1fr)`;
  })();

  const handleValueChange = (newValue: string) => {
    onChange?.(newValue);
  };

  const handleOptionClick = (optionValue: string) => {
    if (allowDeselect && value === optionValue) {
      onChange?.("");
    }
    onOptionClick?.(optionValue);
  };

  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>
      )}

      <RadioGroup
        value={value}
        onValueChange={handleValueChange}
        disabled={disabled}
        className={cn("grid gap-3", isCells && "w-full justify-start")}
        style={{
          gridTemplateColumns: gridColumns,
          maxWidth: isCells
            ? `calc(${effectiveItemsPerRow} * 3.75rem + (${effectiveItemsPerRow} - 1) * 0.75rem)`
            : undefined,
        }}
        {...radioGroupProps}
      >
        {options.map((option, index) => {
          const optionId = `${groupId}-${option.value}`;
          const isChecked = value === option.value;
          const isDisabled = disabled || option.disabled;

          const renderIndicator = () => {
            return (
              <>
                <RadioGroupItem
                  id={optionId}
                  value={option.value}
                  disabled={isDisabled}
                  className={cn(
                    effectiveIndicatorVariant !== "radio"
                      ? "sr-only peer"
                      : "group-hover/option:data-[state=checked]:ring-primary/60",
                  )}
                  onClick={() => handleOptionClick(option.value)}
                />
                {effectiveIndicatorVariant === "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={cn(
                  getOptionClassName({
                    selected: isChecked,
                    highlightOnHover,
                    showOutlineOnSelect,
                    showFillOnSelect,
                    showShadowOnSelect,
                    disabled: isDisabled,
                    allowTextSelection,
                  }),
                  effectiveIndicatorVariant === "radio" && "group/option",
                  isCells && "h-auto aspect-square text-sm justify-center text-center",
                )}
              >
                <div className="flex items-center gap-3">
                  {showNumbers && !isCells && (
                    <span className="shrink-0 w-6 h-6 flex items-center justify-center rounded-full bg-muted text-sm font-medium">
                      {index + 1}
                    </span>
                  )}

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

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

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

More from turbopills-ui

Browse all components in this library →