Velocity UI
Loading…
Menu

Radio Group Collection

Premium radio group variants featuring Modern, Card-based, Neon, and Animated interaction models.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add radio-stack

Source Code

radio-stack.tsx
'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

radio-basic.tsx
 '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

radio-capsule.tsx
 '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

radio-glow.tsx
 '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

radio-pill.tsx
 'use client'
 
 import { useState } from 'react'
 import { motion } from 'framer-motion'
 
 export default function RadioPill() {
   const [value, setValue] = useState('apple')
   const options = ['apple', 'google', 'github']
   return (
     <div className="relative flex items-center gap-2 p-2 rounded-full bg-muted/30 border border-border/50">
       <motion.div
         layout
         className="absolute top-1/2 -translate-y-1/2 rounded-full bg-background shadow-sm border border-border"
         style={{ width: 90, height: 38, left: options.indexOf(value) * 96 + 2 }}
         transition={{ type: 'spring', stiffness: 400, damping: 28 }}
       />
       {options.map((opt) => (
         <button
           key={opt}
           onClick={() => setValue(opt)}
           className="relative w-[90px] h-[38px] rounded-full text-xs font-semibold z-10"
         >
           <motion.span
             animate={{ scale: value === opt ? 1.05 : 0.95, opacity: value === opt ? 1 : 0.7 }}
             transition={{ type: 'spring', stiffness: 450, damping: 24 }}
             className={value === opt ? 'text-foreground' : 'text-muted-foreground'}
           >
             {opt.toUpperCase()}
           </motion.span>
         </button>
       ))}
     </div>
   )
 }

Source Code

radio-segmented.tsx
 '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

radio-toggle.tsx
 'use client'
 
 import { useState } from 'react'
 import { motion } from 'framer-motion'
 
 export default function RadioToggle() {
   const [value, setValue] = useState<'on' | 'off'>('off')
   return (
     <div className="relative w-[220px] p-2 rounded-2xl bg-background/50 border border-border/60">
       <div className="relative h-10 flex items-center justify-between px-2">
         <motion.div
           layout
           className="absolute top-0 bottom-0 rounded-xl bg-foreground/5 border border-border/50"
           style={{ width: 100, left: value === 'on' ? 110 : 10 }}
           transition={{ type: 'spring', stiffness: 380, damping: 28 }}
         />
         <button
           onClick={() => setValue('off')}
           className="relative w-[100px] h-10 grid place-items-center rounded-xl z-10 text-xs font-semibold"
         >
           <motion.span
             animate={{ scale: value === 'off' ? 1.06 : 1, opacity: value === 'off' ? 1 : 0.7 }}
             transition={{ type: 'spring', stiffness: 420, damping: 26 }}
             className={value === 'off' ? 'text-foreground' : 'text-muted-foreground'}
           >
             OFF
           </motion.span>
         </button>
         <button
           onClick={() => setValue('on')}
           className="relative w-[100px] h-10 grid place-items-center rounded-xl z-10 text-xs font-semibold"
         >
           <motion.span
             animate={{ scale: value === 'on' ? 1.06 : 1, opacity: value === 'on' ? 1 : 0.7 }}
             transition={{ type: 'spring', stiffness: 420, damping: 26 }}
             className={value === 'on' ? 'text-foreground' : 'text-muted-foreground'}
           >
             ON
           </motion.span>
         </button>
       </div>
     </div>
   )
 }

Dependencies

  • framer-motion: latest
  • lucide-react: latest
  • clsx: latest
  • tailwind-merge: latest

Props

Component property reference.

NameTypeDefaultDescription
optionsRadioOption[][]Array of radio options with value, label, and optional icon.
valuestringundefinedThe currently selected value.
onChange(value: string) => voidundefinedCallback function when a selection is made.
Context Worth Keeping In Orbit

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.