Motion radio groups
Five radio groups where the selection indicator moves between options with a shared layout animation: a slide, a pop, a cross-fade, a spring and a morph. 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-motionDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/radio-group-motion.tsx"use client"
import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Motion radio-group family: 5 selection groups where the selection marker is
carried between options with a framer layoutId. The variants differ in the
character of the transition: slide, pop, cross-fade, spring and shape change. The
layoutId is unique per instance via useId (so that 25 examples on the same page
do not animate across each other). role="radiogroup" + role="radio", roving
tabindex, arrow-key navigation and selection. Color comes ONLY from tokens, via
alpha color-mix. Under reduced-motion the marker jumps 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 padSize: Record<StyledSize, string> = {
sm: "px-2.5 py-1",
md: "px-3 py-1.5",
lg: "px-4 py-2",
xl: "px-5 py-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]
)
return { opts, selected, select, reduce }
}
/* 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"
type IndicatorMotion = {
initial?: Record<string, number>
animate?: Record<string, number>
exit?: Record<string, number>
}
/* Shared shell: a horizontal option row plus an indicator carried by layoutId. */
function MotionShell({
props,
transition,
indicatorClass,
indicatorMotion,
activeText = "text-primary-foreground",
trackClass = "bg-muted",
}: {
props: RadioGroupProps
transition: Record<string, unknown>
indicatorClass: string
indicatorMotion?: IndicatorMotion
activeText?: string
trackClass?: string
}) {
const { className, size = "md", options, value, defaultValue, onValueChange } = props
const { opts, selected, select, reduce } = useRadioGroup({ options, value, defaultValue, onValueChange })
const { setRef, onKeyDown } = useRadioKeys(opts, select)
const groupId = React.useId()
const activeIndex = opts.findIndex((o) => o.value === selected)
const t = reduce ? { duration: 0 } : transition
const anim = reduce ? undefined : indicatorMotion
return (
<div
data-slot="styled-radio-group"
role="radiogroup"
aria-label="Choose an option"
className={cn("inline-flex items-center gap-1 rounded-lg p-1", trackClass, 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(
"relative z-10 cursor-pointer rounded-md font-medium transition-colors",
focusRing,
textSize[size],
padSize[size],
active ? activeText : "text-muted-foreground hover:text-foreground"
)}
>
{active ? (
<motion.span
layoutId={`motion-radio-${groupId}`}
transition={t}
initial={anim?.initial}
animate={anim?.animate}
className={cn("absolute inset-0 -z-10", indicatorClass)}
/>
) : null}
<span className="relative">{opt.label}</span>
</button>
)
})}
</div>
)
}
/* Slide: the marker slides between the options with a plain tween. */
export function SlideRadio(props: RadioGroupProps) {
return (
<MotionShell
props={props}
transition={{ type: "tween" as const, duration: 0.28, ease: "easeInOut" }}
indicatorClass="rounded-md bg-primary shadow-sm"
/>
)
}
/* Pop: the indicator does a short scale pop while it travels. */
export function PopRadio(props: RadioGroupProps) {
return (
<MotionShell
props={props}
transition={{ type: "spring" as const, stiffness: 700, damping: 22 }}
indicatorMotion={{ initial: { scale: 0.7, opacity: 0.6 }, animate: { scale: 1, opacity: 1 } }}
indicatorClass="rounded-md bg-primary shadow-sm"
/>
)
}
/* Fade: isaret tasinirken capraz gecisle solar ve belirir. */
export function FadeRadio(props: RadioGroupProps) {
return (
<MotionShell
props={props}
transition={{ type: "tween" as const, duration: 0.32, ease: "easeOut" }}
indicatorMotion={{ initial: { opacity: 0 }, animate: { opacity: 1 } }}
activeText="text-primary"
indicatorClass="rounded-md border border-primary bg-[color-mix(in_oklab,var(--color-primary)_14%,transparent)]"
/>
)
}
/* Spring: yayli, hafif zipli tasima. */
export function SpringRadio(props: RadioGroupProps) {
return (
<MotionShell
props={props}
transition={{ type: "spring" as const, stiffness: 320, damping: 16 }}
indicatorClass="rounded-full bg-primary shadow-sm"
/>
)
}
/* Morph: isaret tasinirken alt cizgiden dolu pill'e sekil degistirir. */
export function MorphRadio(props: RadioGroupProps) {
return (
<MotionShell
props={props}
transition={{ type: "spring" as const, stiffness: 420, damping: 30 }}
indicatorMotion={{ initial: { scaleY: 0.18, opacity: 0.5 }, animate: { scaleY: 1, opacity: 1 } }}
trackClass="bg-transparent"
indicatorClass="rounded-full border border-primary bg-[color-mix(in_oklab,var(--color-primary)_18%,transparent)]"
activeText="text-primary"
/>
)
}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.
Slide
The indicator glides between options with an even tween.
import { SlideRadio } from "@/components/ui/radio-group-motion"
<SlideRadio />Pop
The indicator pops in scale as it lands.
import { PopRadio } from "@/components/ui/radio-group-motion"
<PopRadio />Fade
The indicator cross-fades while it travels.
import { FadeRadio } from "@/components/ui/radio-group-motion"
<FadeRadio />Spring
A bouncy spring carries the pill indicator.
import { SpringRadio } from "@/components/ui/radio-group-motion"
<SpringRadio />Morph
The indicator morphs from a thin bar into a full pill.
import { MorphRadio } from "@/components/ui/radio-group-motion"
<MorphRadio />ai2 Motion radio groups: 5 styled variations on the token system
The ai2 Motion 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 whose indicator animates between options. 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 moves one shared-layout indicator from the old option to the new one. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the indicator jumps to the selected option instantly.
What is in the ai2 Motion radio groups?
5 exports in one file: Slide, Pop, Fade, Spring and Morph. 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 moves one shared-layout indicator from the old option to the new one.
- Reduced-motion aware: Under prefers-reduced-motion, the indicator jumps to the selected option 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 Motion 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.