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