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 · lighticonslucide-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>
);
}