List radio groups
Five radio groups that keep one dot-and-label language and vary only the layout: spaced rows, divided rows, a grouped card, an inline row and a dense list. Each is self-contained, sized, token-driven and keyboard accessible with role radiogroup.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/radio-group-listDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/radio-group-list.tsx"use client"
import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* List radio-group family: 5 list layouts. The visual language is the same (a token
dot + a label); the difference is the LAYOUT: full-width rows, rows with a
divider, a bordered grouped block, a horizontal inline arrangement and a dense
list. role="radiogroup" + role="radio", roving tabindex, arrow-key navigation and
selection. Color comes ONLY from tokens, via alpha color-mix. A dot box under
24px carries an invisible touch area. Under reduced-motion the dot appears
instantly. Renders with no props too. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
type RadioOption = { value: string; label: React.ReactNode }
interface RadioGroupProps {
className?: string
size?: StyledSize
options?: RadioOption[]
value?: string
defaultValue?: string
onValueChange?: (v: string) => void
}
const DEFAULT_OPTIONS: RadioOption[] = [
{ value: "a", label: "Option A" },
{ value: "b", label: "Option B" },
{ value: "c", label: "Option C" },
]
const textSize: Record<StyledSize, string> = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
xl: "text-lg",
}
const rowPad: Record<StyledSize, string> = {
sm: "px-2.5 py-1.5",
md: "px-3 py-2",
lg: "px-4 py-2.5",
xl: "px-5 py-3",
}
const densePad: Record<StyledSize, string> = {
sm: "px-2 py-0.5",
md: "px-2 py-1",
lg: "px-2.5 py-1.5",
xl: "px-3 py-1.5",
}
const ringSize: Record<StyledSize, string> = {
sm: "size-4",
md: "size-4",
lg: "size-5",
xl: "size-5",
}
const dotSize: Record<StyledSize, string> = {
sm: "size-1.5",
md: "size-2",
lg: "size-2.5",
xl: "size-2.5",
}
function useRadioGroup(props: Pick<RadioGroupProps, "value" | "defaultValue" | "onValueChange" | "options">) {
const { value, defaultValue, onValueChange, options } = props
const opts = options && options.length > 0 ? options : DEFAULT_OPTIONS
const reduce = useReducedMotion()
const [internal, setInternal] = React.useState(defaultValue ?? opts[0]?.value)
const selected = value ?? internal
const select = React.useCallback(
(v: string) => {
if (value === undefined) setInternal(v)
onValueChange?.(v)
},
[value, onValueChange]
)
const spring = reduce
? { duration: 0 }
: ({ type: "spring" as const, stiffness: 500, damping: 34 })
return { opts, selected, select, reduce, spring }
}
/* Arrow-key navigation + selection (together with the roving tabindex). */
function useRadioKeys(opts: RadioOption[], select: (v: string) => void) {
const refs = React.useRef<(HTMLButtonElement | null)[]>([])
const setRef = (i: number) => (el: HTMLButtonElement | null) => {
refs.current[i] = el
}
const onKeyDown = (i: number) => (e: React.KeyboardEvent) => {
const last = opts.length - 1
let next = -1
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = i === last ? 0 : i + 1
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = i === 0 ? last : i - 1
if (next < 0) return
e.preventDefault()
const target = opts[next]
if (!target) return
select(target.value)
refs.current[next]?.focus()
}
return { setRef, onKeyDown }
}
const focusRing =
"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/* Shared shell: the layout classes come from outside, the marking is the same in every variant. */
function ListShell({
props,
rootClass,
rowClass,
activeClass,
inactiveClass = "text-muted-foreground hover:text-foreground",
pad,
gapClass = "gap-2.5",
}: {
props: RadioGroupProps
rootClass: string
rowClass?: string
activeClass: string
inactiveClass?: string
pad: Record<StyledSize, string>
gapClass?: string
}) {
const { className, size = "md", options, value, defaultValue, onValueChange } = props
const { opts, selected, select, spring } = useRadioGroup({ options, value, defaultValue, onValueChange })
const { setRef, onKeyDown } = useRadioKeys(opts, select)
const activeIndex = opts.findIndex((o) => o.value === selected)
return (
<div
data-slot="styled-radio-group"
role="radiogroup"
aria-label="Choose an option"
className={cn(rootClass, className)}
>
{opts.map((opt, i) => {
const active = opt.value === selected
const roving = active || (activeIndex < 0 && i === 0)
return (
<button
key={opt.value}
ref={setRef(i)}
type="button"
role="radio"
aria-checked={active}
tabIndex={roving ? 0 : -1}
onKeyDown={onKeyDown(i)}
onClick={() => select(opt.value)}
data-slot="styled-radio-item"
className={cn(
/* min-h-6: in the dense variant py-0.5/py-1 plus a leading-none line dropped the row to 20px (measured: 89x20) and neighbouring options came closer than 24px. The base height is pinned to 24 independently of the padding; the visual padding is preserved exactly. */
"group flex min-h-6 cursor-pointer items-center text-left font-medium transition-colors",
gapClass,
focusRing,
textSize[size],
pad[size],
rowClass,
active ? activeClass : inactiveClass
)}
>
<span
className={cn(
"relative flex shrink-0 items-center justify-center rounded-full border transition-colors after:absolute after:-inset-1.5",
ringSize[size],
active ? "border-primary" : "border-field-border group-hover:border-[color-mix(in_oklab,var(--color-primary)_40%,transparent)]"
)}
>
<motion.span
initial={false}
animate={{ scale: active ? 1 : 0 }}
transition={spring}
className={cn("rounded-full bg-primary", dotSize[size])}
/>
</span>
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
</button>
)
})}
</div>
)
}
/* Row: full-width, spaced rows; the selected row gets a light token fill. */
export function RowRadio(props: RadioGroupProps) {
return (
<ListShell
props={props}
rootClass="flex w-full flex-col gap-1"
rowClass="w-full rounded-lg"
pad={rowPad}
activeClass="bg-[color-mix(in_oklab,var(--color-primary)_10%,transparent)] text-foreground"
inactiveClass="text-muted-foreground hover:bg-muted hover:text-foreground"
/>
)
}
/* Divided: satirlar arasi ince ayrac cizgisi. */
export function DividedRadio(props: RadioGroupProps) {
return (
<ListShell
props={props}
rootClass="flex w-full flex-col divide-y divide-border"
rowClass="w-full"
pad={rowPad}
activeClass="text-foreground"
/>
)
}
/* Grouped: a single bordered block; the rows are joined and the selected one takes a tint. */
export function GroupedRadio(props: RadioGroupProps) {
return (
<ListShell
props={props}
rootClass="flex w-full flex-col divide-y divide-border overflow-hidden rounded-xl border border-border bg-card"
rowClass="w-full"
pad={rowPad}
activeClass="bg-[color-mix(in_oklab,var(--color-primary)_12%,transparent)] text-foreground"
inactiveClass="text-muted-foreground hover:bg-muted hover:text-foreground"
/>
)
}
/* Inline: a horizontal, inline arrangement; it wraps in narrow spaces. */
export function InlineRadio(props: RadioGroupProps) {
return (
<ListShell
props={props}
rootClass="flex flex-wrap items-center gap-x-4 gap-y-2"
rowClass="rounded-md"
gapClass="gap-2"
pad={densePad}
activeClass="text-foreground"
/>
)
}
/* Dense: siki aralikli, kompakt liste. */
export function DenseRadio(props: RadioGroupProps) {
return (
<ListShell
props={props}
rootClass="flex w-full flex-col gap-0.5"
rowClass="w-full rounded-md"
gapClass="gap-2"
pad={densePad}
activeClass="text-foreground"
inactiveClass="text-muted-foreground hover:text-foreground"
/>
)
}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.
Row
Full-width rows that tint when selected.
import { RowRadio } from "@/components/ui/radio-group-list"
<RowRadio />Divided
Rows separated by a hairline divider.
import { DividedRadio } from "@/components/ui/radio-group-list"
<DividedRadio />Grouped
Rows joined inside one bordered card.
import { GroupedRadio } from "@/components/ui/radio-group-list"
<GroupedRadio />Inline
Options laid out inline and wrapping.
import { InlineRadio } from "@/components/ui/radio-group-list"
<InlineRadio />Dense
A compact list for tight sidebars and filters.
import { DenseRadio } from "@/components/ui/radio-group-list"
<DenseRadio />ai2 List radio groups: 5 styled variations on the token system
The ai2 List radio groups are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around single-choice selection controls arranged as lists. 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: framer-motion springs the dot fill on select. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the dot fill appears instantly.
What is in the ai2 List radio groups?
5 exports in one file: Row, Divided, Grouped, Inline and Dense. 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: framer-motion springs the dot fill on select.
- Reduced-motion aware: Under prefers-reduced-motion, the dot fill appears 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 List radio groups 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.