Forms
RadioSphere
A real radio group where one glossy sphere rolls from the old pick into the new one. Arrow keys work like any native radio group.
Download
- React
- Tailwind CSS v4
- motion
components/ui/radio-sphere.tsx
"use client";
import { useId, useState, type CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";
export type RadioSphereOption = {
value: string;
label: string;
/** Small text under the label. */
hint?: string;
disabled?: boolean;
};
type RadioSphereProps = {
options: RadioSphereOption[];
/** Group name shown above the choices. */
label: string;
/** Picked value (controlled). */
value?: string;
/** Picked value at start (uncontrolled). */
defaultValue?: string;
/** Called with the new value. */
onChange?: (value: string) => void;
/** Form field name. */
name?: string;
/** Sphere and ring colour. */
accent?: string;
/** Wrapper look (width etc.). */
className?: string;
};
/* RadioSphere: a real radio group where one glossy sphere rolls from the old pick into the new one. Arrow keys work like any native radio group. */
export function RadioSphere({
options,
label,
value,
defaultValue,
onChange,
name,
accent = "#a78bfa",
className = "w-full max-w-xs",
}: RadioSphereProps) {
const reduce = useReducedMotion();
const uid = useId();
const [inner, setInner] = useState(defaultValue);
const selected = value ?? inner;
return (
<fieldset className={className} style={{ "--rs": accent } as CSSProperties}>
<legend className="mb-2 block text-sm font-medium text-white/80">{label}</legend>
<div className="grid gap-1">
{options.map((o) => {
const picked = o.value === selected;
return (
<label
key={o.value}
className="flex min-h-11 cursor-pointer select-none items-center gap-3 rounded-xl px-3 py-2 transition-colors duration-150 hover:bg-white/[0.04] has-checked:bg-white/[0.06] has-disabled:cursor-not-allowed has-disabled:opacity-40 has-disabled:hover:bg-transparent motion-reduce:transition-none"
>
<span className="relative size-6 shrink-0">
<input
type="radio"
name={name ?? uid}
value={o.value}
checked={picked}
disabled={o.disabled}
onChange={() => {
if (value === undefined) setInner(o.value);
onChange?.(o.value);
}}
className="peer sr-only"
/>
<span className="absolute inset-0 grid place-items-center rounded-full border border-white/25 transition-colors duration-150 peer-checked:border-[var(--rs)] peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--rs)] peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-[#0b0a14] motion-reduce:transition-none">
{picked && (
<motion.span
layoutId={`${uid}-sphere`}
aria-hidden
className="size-4 rounded-full"
style={{
background: "radial-gradient(circle at 34% 30%, #fff, var(--rs) 48%, color-mix(in srgb, var(--rs) 40%, #000))",
boxShadow: "0 0 12px color-mix(in srgb, var(--rs) 60%, transparent)",
}}
initial={reduce ? false : { rotate: -200 }}
animate={{ rotate: 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 360, damping: 26 }}
/>
)}
</span>
</span>
<span className="min-w-0">
<span className="block text-[15px] font-medium text-white">{o.label}</span>
{o.hint && <span className="block text-[13px] text-white/50">{o.hint}</span>}
</span>
</label>
);
})}
</div>
</fieldset>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/radio-sphere.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/radio-sphere-demo.tsx
"use client"; import { useState } from "react"; import { RadioSphere } from "@/components/ui/radio-sphere"; const OPTIONS = [ { value: "starter", label: "Starter", hint: "Free for one project" }, { value: "pro", label: "Pro", hint: "₹499 a month" }, { value: "enterprise", label: "Enterprise", hint: "Opens next year", disabled: true }, ]; export default function RadioSphereExample() { const [plan, setPlan] = useState("pro"); return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-5"> <RadioSphere label="Plan" options={OPTIONS} value={plan} onChange={setPlan} name="plan" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| options | { value, label, hint?, disabled? }[] | — | Choices. hint = small text under the label. |
| label | string | — | Group name above the choices. |
| value | string | — | Picked value (controlled). |
| defaultValue | string | — | Picked value at start (uncontrolled). |
| onChange | (value: string) => void | — | Called with the new value. |
| name | string | — | Form field name. |
| accent | string | "#a78bfa" | Sphere and ring colour. |
| className | string | "w-full max-w-xs" | Wrapper look. |