Radio Group
Not the flashiest of components, but this is where my love for functional interaction design comes into play. I created a hover interaction where a user can start the animation by hovering any of the bubbles. Whether you go up or down, the bubble seems to follow you to the next one displaying interactivity and a sense of direction since the hover bubble follows mouse movement.
Props & Styling
Props
RadioGroup
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled selected value. |
defaultValue | string | — | Initial value when uncontrolled. |
onValueChange | (value: string) => void | — | Called when the selection changes. |
name | string | generated via useId() | Native name shared by all RadioItems so browser radio grouping works without manual wiring. |
RadioItem (extends InputHTMLAttributes<HTMLInputElement>)
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — (required) | This item's value, compared against the group's selected value. |
id | string | generated via useId() | Native id, also used as the item's registration key. |
children | ReactNode | falls back to value | Label content. |
Styling
A real <input type="radio"> sits invisibly (opacity-0) on top of the visual circle, so keyboard navigation, form submission, and screen readers all work exactly as they would for native radios — the visible dot/ring is purely decorative motion layered on top (scale/opacity, 0.15s easeOut).
The hover "bubble" that appears behind whichever item you're pointing at travels toward your mouse: each item registers its index on mount, and when the hovered index changes, the group compares it to the previous one to derive a direction ('up'/'down'), which flips the bubble's enter/exit offset accordingly — the bubble always animates from the direction you're moving, not from a fixed origin.
Code
import React, { useCallback, useId, useState, useMemo } from "react";
interface RadioGroupContextValue {
value?: string;
hoveredIndex: number | null;
direction: 'up' | 'down' | null;
onValueChange: (value: string) => void;
onHoverChange: (index: number | null) => void;
handleRegister: (id:string) => () => void;
registeredIds: string[]
name: string;
}
interface RadioGroupProps {
children?: React.ReactNode;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
name?: string;
}
const RadioGroupContext = React.createContext<RadioGroupContextValue | null>(null)
const RadioGroup = ({ value, children, name: propName, onValueChange, defaultValue }: RadioGroupProps) => {
const generatedId = useId();
const name = propName ?? generatedId;
const [selectedValue, setSelectedValue] = useState<string | undefined>(value ?? defaultValue);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [movement, setMovement] = useState<'up' | 'down' | null>(null);
const [registeredIds, setRegisteredIds] = useState<string[]>([]);
const handleValueChange = (newValue: string) => {
setSelectedValue(newValue);
onValueChange?.(newValue);
};
const handleHoverChange = (nextIndex: number | null) => {
if (nextIndex === null || hoveredIndex === null) {
setMovement(null);
} else if (nextIndex < hoveredIndex) {
setMovement('up');
} else if (nextIndex > hoveredIndex) {
setMovement('down');
}
setHoveredIndex(nextIndex);
};
const handleRegister = useCallback((id: string) => {
setRegisteredIds(prev => {
if(!prev.includes(id)) return ([...prev,id])
else {
return(prev)
}
})
return () => setRegisteredIds(prev => prev.filter(item => item !== id))
}, []);
const memoizedValues = useMemo(() => {
return(
{
value: selectedValue,
onValueChange: handleValueChange,
hoveredIndex,
handleRegister,
registeredIds,
direction: movement,
onHoverChange: handleHoverChange,
name
}
)
},[selectedValue, onValueChange, hoveredIndex, handleRegister, registeredIds, movement, handleHoverChange, name ])
return (
<RadioGroupContext.Provider value={memoizedValues}>
<div
role="radiogroup"
onMouseLeave={() => handleHoverChange(null)}
>
{children}
</div>
</RadioGroupContext.Provider>
);
};
export const useRadioGroupContext = () => {
const context = React.useContext(RadioGroupContext);
if (!context) {
throw new Error("RadioItem must be used within a RadioGroup");
}
return context;
};
export default RadioGroup;