Radio Group Collection
Premium radio group variants featuring Modern, Card-based, Neon, and Animated interaction models.
Preview
Live interactive preview.
Installation
Add this component to your project using the CLI:
npx -y vui-registry-cli-v1@latest add radio-stackSource Code
import type { ComponentType } from 'react'
import { AppleIcon } from '@/components/icons/tech-svgs'
export const GoogleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
)
export const GithubBrandIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
)
export type PillOption = {
id: 'apple' | 'google' | 'github'
label: string
Icon: ComponentType<{ className?: string }>
}
export const PILL_OPTIONS: PillOption[] = [
{ id: 'apple', label: 'Apple', Icon: AppleIcon },
{ id: 'google', label: 'Google', Icon: GoogleIcon },
{ id: 'github', label: 'GitHub', Icon: GithubBrandIcon },
]
export const PILL_ITEM_WIDTH = 118
Source Code
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
export default function RadioBasic() {
const [value, setValue] = useState('a')
const options = ['a', 'b', 'c']
return (
<div className="flex items-center gap-3 p-3 rounded-2xl border border-border bg-background/40 backdrop-blur-sm">
{options.map((opt, i) => (
<button
key={opt}
onClick={() => setValue(opt)}
className="relative px-3.5 py-2 rounded-xl text-sm font-medium"
>
<motion.span
animate={{ opacity: value === opt ? 1 : 0.6, scale: value === opt ? 1.02 : 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 24 }}
className={value === opt ? 'text-primary' : 'text-muted-foreground'}
>
{opt.toUpperCase()}
</motion.span>
{value === opt && (
<motion.div
layoutId="radio-basic"
className="absolute inset-0 rounded-xl bg-primary/10 border border-primary/20"
transition={{ type: 'spring', stiffness: 350, damping: 26 }}
/>
)}
</button>
))}
</div>
)
}
Source Code
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
export default function RadioCapsule() {
const [value, setValue] = useState('xs')
const options = ['xs', 'sm', 'md', 'lg']
const w = 52
return (
<div className="relative px-2 py-2 rounded-full bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800">
<div className="relative flex items-center">
<motion.div
layout
className="absolute top-0 bottom-0 rounded-full bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 shadow-sm"
style={{ width: w, left: options.indexOf(value) * w }}
transition={{ type: 'spring', stiffness: 380, damping: 28 }}
/>
{options.map((opt) => (
<button
key={opt}
onClick={() => setValue(opt)}
className="relative w-[52px] h-10 grid place-items-center rounded-full z-10 text-xs font-semibold"
>
<span className={value === opt ? 'text-black dark:text-white' : 'text-zinc-500'}>
{opt.toUpperCase()}
</span>
</button>
))}
</div>
</div>
)
}
Source Code
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
export default function RadioGlow() {
const [value, setValue] = useState('one')
const options = ['one', 'two', 'three', 'four']
return (
<div className="relative flex items-center gap-2 p-3 rounded-2xl bg-black border border-neutral-800">
{options.map((opt) => (
<button
key={opt}
onClick={() => setValue(opt)}
className="relative w-16 h-10 grid place-items-center rounded-xl z-10 text-xs font-semibold"
>
<span className={value === opt ? 'text-white' : 'text-neutral-400'}>{opt.toUpperCase()}</span>
{value === opt && (
<motion.div
layoutId="radio-glow"
className="absolute inset-0 rounded-xl bg-indigo-500/15 border border-indigo-500/40"
style={{ boxShadow: '0 0 18px 2px rgba(99,102,241,0.35), inset 0 0 10px rgba(99,102,241,0.2)' }}
transition={{ type: 'spring', stiffness: 320, damping: 24 }}
/>
)}
</button>
))}
</div>
)
}
Source Code
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
import { PILL_OPTIONS, PILL_ITEM_WIDTH } from './pill-brand-icons'
export default function RadioPill() {
const [value, setValue] = useState<(typeof PILL_OPTIONS)[number]['id']>('apple')
const activeIndex = PILL_OPTIONS.findIndex((opt) => opt.id === value)
return (
<div className="inline-flex p-1.5 rounded-full bg-muted/30 border border-border/50 overflow-hidden">
<div className="relative flex items-center">
<motion.div
layoutId="radio-pill-indicator"
className="absolute inset-y-1 rounded-full bg-background border border-border"
style={{
width: PILL_ITEM_WIDTH,
left: activeIndex * PILL_ITEM_WIDTH + 2,
}}
transition={{ type: 'spring', stiffness: 420, damping: 32 }}
/>
{PILL_OPTIONS.map((opt, index) => {
const Icon = opt.Icon
const isActive = value === opt.id
const isLast = index === PILL_OPTIONS.length - 1
return (
<button
key={opt.id}
type="button"
onClick={() => setValue(opt.id)}
style={{ width: PILL_ITEM_WIDTH }}
className={`relative h-9 flex items-center justify-center gap-1.5 rounded-full z-10 shrink-0 ${
isLast ? 'pr-2' : ''
}`}
>
<Icon className={`w-4 h-4 shrink-0 ${isActive ? 'opacity-100' : 'opacity-60'}`} />
<span
className={`text-[11px] font-semibold capitalize ${
isActive ? 'text-foreground' : 'text-muted-foreground'
}`}
>
{opt.label}
</span>
</button>
)
})}
</div>
</div>
)
}
Source Code
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
export default function RadioSegmented() {
const [value, setValue] = useState('weekly')
const options = ['daily', 'weekly', 'monthly']
return (
<div className="relative flex items-center gap-1 p-1 rounded-2xl bg-background/50 border border-border/60 shadow-inner">
{options.map((opt) => (
<button
key={opt}
onClick={() => setValue(opt)}
className="relative px-4 h-10 grid place-items-center rounded-xl z-10 text-xs font-semibold"
>
<motion.span
animate={{ scale: value === opt ? 1.05 : 1, opacity: value === opt ? 1 : 0.7 }}
transition={{ type: 'spring', stiffness: 420, damping: 26 }}
className={value === opt ? 'text-foreground' : 'text-muted-foreground'}
>
{opt.toUpperCase()}
</motion.span>
{value === opt && (
<motion.div
layoutId="radio-segment"
className="absolute inset-0 rounded-xl bg-primary/8 border border-primary/20"
transition={{ type: 'spring', stiffness: 360, damping: 24 }}
/>
)}
</button>
))}
</div>
)
}
Source Code
'use client'
import React, { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import RadioBasic from './radio-basic'
import RadioPill from './radio-pill'
import RadioGlow from './radio-glow'
import RadioCapsule from './radio-capsule'
import RadioSegmented from './radio-segmented'
import RadioToggle from './radio-toggle'
export default function RadioStack() {
const [copied, setCopied] = useState<string | null>(null)
function copy(name: string, cmd: string) {
navigator.clipboard.writeText(cmd)
setCopied(name)
setTimeout(() => setCopied(null), 1500)
}
const variants = [
{ name: 'Basic', Component: RadioBasic, cmd: 'npx vui-registry-cli-v1 add radio-basic' },
{ name: 'Pill', Component: RadioPill, cmd: 'npx vui-registry-cli-v1 add radio-pill' },
{ name: 'Glow', Component: RadioGlow, cmd: 'npx vui-registry-cli-v1 add radio-glow' },
{ name: 'Capsule', Component: RadioCapsule, cmd: 'npx vui-registry-cli-v1 add radio-capsule' },
{ name: 'Segmented', Component: RadioSegmented, cmd: 'npx vui-registry-cli-v1 add radio-segmented' },
{ name: 'Toggle', Component: RadioToggle, cmd: 'npx vui-registry-cli-v1 add radio-toggle' },
]
return (
<div className="w-full grid gap-6 md:grid-cols-2 p-4">
{variants.map((v) => (
<div key={v.name} className="rounded-2xl p-[2px] border border-border bg-card/50 backdrop-blur-sm overflow-hidden">
<div className="rounded-xl border border-border bg-background/60">
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-muted/10 relative">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{v.name}</div>
<button
onClick={() => copy(v.name, v.cmd)}
className="text-[10px] font-mono border-b border-border absolute top-2 right-3"
>
<AnimatePresence mode="wait">
{copied === v.name ? (
<motion.span key="copied" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="text-emerald-500">
Copied
</motion.span>
) : (
<motion.span key="copy" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
CLI
</motion.span>
)}
</AnimatePresence>
</button>
</div>
<div className="p-6 min-h-[160px] grid place-items-center">
<v.Component />
</div>
</div>
</div>
))}
</div>
)
}
Source Code
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
const SEGMENT_W = 104
export default function RadioToggle() {
const [value, setValue] = useState<'on' | 'off'>('off')
return (
<div className="inline-flex p-1 rounded-2xl bg-muted/30 border border-border/50">
<div className="relative flex items-center">
<motion.div
className="absolute inset-y-0 rounded-xl bg-background border border-border shadow-sm"
style={{ width: SEGMENT_W }}
animate={{ x: value === 'on' ? SEGMENT_W : 0 }}
transition={{ type: 'spring', stiffness: 380, damping: 28 }}
/>
<button
type="button"
onClick={() => setValue('off')}
style={{ width: SEGMENT_W }}
className="relative h-10 grid place-items-center rounded-xl z-10 text-xs font-semibold"
>
<motion.span
animate={{ scale: value === 'off' ? 1.04 : 1, opacity: value === 'off' ? 1 : 0.65 }}
transition={{ type: 'spring', stiffness: 420, damping: 26 }}
className={value === 'off' ? 'text-foreground' : 'text-muted-foreground'}
>
OFF
</motion.span>
</button>
<button
type="button"
onClick={() => setValue('on')}
style={{ width: SEGMENT_W }}
className="relative h-10 grid place-items-center rounded-xl z-10 text-xs font-semibold"
>
<motion.span
animate={{ scale: value === 'on' ? 1.04 : 1, opacity: value === 'on' ? 1 : 0.65 }}
transition={{ type: 'spring', stiffness: 420, damping: 26 }}
className={value === 'on' ? 'text-foreground' : 'text-muted-foreground'}
>
ON
</motion.span>
</button>
</div>
</div>
)
}
Dependencies
framer-motion: latestlucide-react: latestclsx: latesttailwind-merge: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| options | RadioOption[] | [] | Array of radio options with value, label, and optional icon. |
| value | string | undefined | The currently selected value. |
| onChange | (value: string) => void | undefined | Callback function when a selection is made. |
Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.
I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.
I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.