Select items
Five list rows with selection that actually works: a checkbox row, a radio group, a switch row, a multi-select list with a live count, and a row that is itself a toggle. Nothing is faked. Each variation holds its own React state, is reachable by keyboard, labels its control, and generates ids with React.useId so server and client agree.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/item-selectDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install lucide-reactCopy the source
components/ui/item-select.tsx"use client"
import * as React from "react"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
/* Item select family: 5 list rows carrying a REAL working selection state. Nothing is faked - every variant holds its own React state, the state changes on click and by keyboard, and the selected appearance comes from that state. The controls get an accessible label and the ids are produced with React.useId() (deterministic, SSR safe). Colour comes ONLY from tokens; alpha via color-mix. No animation. Every export renders without props too. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const box: Record<StyledSize, string> = {
sm: "gap-2.5 rounded-lg p-2 text-sm",
md: "gap-3 rounded-lg p-3 text-sm",
lg: "gap-3.5 rounded-xl p-4 text-base",
xl: "gap-4 rounded-xl p-5 text-base",
}
const base =
"flex w-full items-center [&_svg]:shrink-0 [&_i]:not-italic [&_i]:leading-none"
const titleCls = "truncate font-medium text-foreground"
const descCls = "truncate text-muted-foreground text-[0.85em]"
/* Satir kabugu: secili degilken notr, secili iken brand kenarligi + hafif tint. */
const rowShell =
"cursor-pointer select-none border border-border bg-card text-card-foreground transition-colors duration-(--motion-fast) ease-(--motion-ease) hover:bg-accent has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50"
const rowOn =
"border-[color-mix(in_oklab,var(--color-brand)_45%,transparent)] bg-[color-mix(in_oklab,var(--color-brand)_8%,transparent)] hover:bg-[color-mix(in_oklab,var(--color-brand)_12%,transparent)]"
/* Controls under 24px carry an invisible hit area. */
const controlHit = "relative after:absolute after:-inset-1.5"
type Props = {
className?: string
size?: StyledSize
}
function Body({
title,
description,
}: {
title: React.ReactNode
description?: React.ReactNode
}) {
return (
<span className="flex min-w-0 flex-1 flex-col">
<span className={titleCls}>{title}</span>
{description ? <span className={descCls}>{description}</span> : null}
</span>
)
}
/* Check: one row plus a real checkbox. The whole row is the label, clicking it checks. */
export function CheckItem({ className, size = "md" }: Props) {
const id = React.useId()
const [checked, setChecked] = React.useState(false)
return (
<label
htmlFor={id}
data-slot="styled-item"
data-state={checked ? "checked" : "unchecked"}
className={cn(base, box[size], rowShell, checked && rowOn, className)}
>
<input
id={id}
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className={cn(
"size-4 shrink-0 accent-brand outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
controlHit
)}
/>
<Body title="Email notifications" description="Send a digest every morning" />
</label>
)
}
const radioOptions = [
{ value: "starter", title: "Starter", description: "Up to 3 projects" },
{ value: "pro", title: "Pro", description: "Unlimited projects" },
{ value: "team", title: "Team", description: "Shared workspaces and roles" },
]
/* Radio: a real 3-row radio group - only one can be selected at a time. */
export function RadioItem({ className, size = "md" }: Props) {
const name = React.useId()
const [value, setValue] = React.useState("pro")
return (
<div
role="radiogroup"
aria-label="Plan"
className={cn("flex w-full flex-col gap-2", className)}
>
{radioOptions.map((opt) => {
const on = value === opt.value
return (
<label
key={opt.value}
data-slot="styled-item"
data-state={on ? "checked" : "unchecked"}
className={cn(base, box[size], rowShell, on && rowOn)}
>
<input
type="radio"
name={name}
value={opt.value}
checked={on}
onChange={() => setValue(opt.value)}
className={cn(
"size-4 shrink-0 accent-brand outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
controlHit
)}
/>
<Body title={opt.title} description={opt.description} />
</label>
)
})}
</div>
)
}
/* Toggle: one row plus a real switch (role="switch", aria-checked, Space/Enter). */
export function ToggleItem({ className, size = "md" }: Props) {
const labelId = React.useId()
const [on, setOn] = React.useState(true)
return (
<div
data-slot="styled-item"
data-state={on ? "checked" : "unchecked"}
className={cn(base, box[size], "border border-border bg-card text-card-foreground", className)}
>
<Body title={<span id={labelId}>Two-factor authentication</span>} description="Ask for a code on every new device" />
<button
type="button"
role="switch"
aria-checked={on}
aria-labelledby={labelId}
onClick={() => setOn((v) => !v)}
className={cn(
"inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent px-0.5 outline-none transition-colors duration-(--motion-fast) ease-(--motion-ease) focus-visible:ring-[3px] focus-visible:ring-ring/50",
on ? "bg-brand" : "bg-[color-mix(in_oklab,var(--color-foreground)_18%,transparent)]",
controlHit
)}
>
<span
aria-hidden="true"
className={cn(
"size-4 rounded-full bg-background shadow-sm transition-transform duration-(--motion-fast) ease-(--motion-ease)",
on ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
</div>
)
}
const multiOptions = [
{ value: "design", title: "Design", description: "Figma files and tokens" },
{ value: "engineering", title: "Engineering", description: "Repos and pipelines" },
{ value: "support", title: "Support", description: "Inbox and macros" },
]
/* Multi: multiple selection - 3 rows with a live count of what is selected. */
export function MultiItem({ className, size = "md" }: Props) {
const group = React.useId()
const [selected, setSelected] = React.useState<string[]>(["design"])
const toggle = (value: string) =>
setSelected((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
)
return (
<div
role="group"
aria-label="Workspaces"
className={cn("flex w-full flex-col gap-2", className)}
>
{multiOptions.map((opt) => {
const id = `${group}-${opt.value}`
const on = selected.includes(opt.value)
return (
<label
key={opt.value}
htmlFor={id}
data-slot="styled-item"
data-state={on ? "checked" : "unchecked"}
className={cn(base, box[size], rowShell, on && rowOn)}
>
<input
id={id}
type="checkbox"
checked={on}
onChange={() => toggle(opt.value)}
className={cn(
"size-4 shrink-0 accent-brand outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
controlHit
)}
/>
<Body title={opt.title} description={opt.description} />
{on ? (
<span className="inline-flex h-5 shrink-0 items-center rounded-md bg-brand px-2 text-xs font-medium text-brand-foreground">
Added
</span>
) : null}
</label>
)
})}
<span className="px-1 text-xs text-muted-foreground">
{selected.length} of {multiOptions.length} selected
</span>
</div>
)
}
/* Highlight: no control - the row itself is a toggle. While selected it gets a brand
surface + a trailing checkmark. The state is announced with aria-pressed. */
export function HighlightItem({ className, size = "md" }: Props) {
const [on, setOn] = React.useState(false)
return (
<button
type="button"
aria-pressed={on}
onClick={() => setOn((v) => !v)}
data-slot="styled-item"
data-state={on ? "checked" : "unchecked"}
className={cn(
base,
box[size],
"cursor-pointer border border-border bg-card text-left text-card-foreground outline-none transition-colors duration-(--motion-fast) ease-(--motion-ease) hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50",
on &&
"border-[color-mix(in_oklab,var(--color-brand)_45%,transparent)] bg-[color-mix(in_oklab,var(--color-brand)_10%,transparent)]",
className
)}
>
<Body title="Dark interface" description="Follow the system appearance" />
<span
aria-hidden="true"
className={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded-full transition-colors duration-(--motion-fast) ease-(--motion-ease) [&_svg]:size-3.5",
on ? "bg-brand text-brand-foreground" : "bg-transparent text-transparent"
)}
>
<Check />
</span>
</button>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Check
A single row that is one large label around a real checkbox.
import { CheckItem } from "@/components/ui/item-select"
<CheckItem />Radio
A real radio group of three rows, one selected at a time.
import { RadioItem } from "@/components/ui/item-select"
<RadioItem />Toggle
A trailing switch with role switch and aria-checked.
import { ToggleItem } from "@/components/ui/item-select"
<ToggleItem />Multi
Multiple selection across three rows with a live count.
import { MultiItem } from "@/components/ui/item-select"
<MultiItem />Highlight
The row itself is the toggle, reported through aria-pressed.
import { HighlightItem } from "@/components/ui/item-select"
<HighlightItem />ai2 Select items: 5 styled variations on the token system
The ai2 Select items are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around selectable list rows for settings, plans and pickers. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: selection changes run on token CSS transitions. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the color transitions are disabled and the selected state applies instantly.
What is in the ai2 Select items?
5 exports in one file: Check, Radio, Toggle, Multi and Highlight. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: selection changes run on token CSS transitions.
- Reduced-motion aware: Under prefers-reduced-motion, the color transitions are disabled and the selected state applies instantly.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Select items sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.