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

PropTypeDefaultDescription
valuestringControlled selected value.
defaultValuestringInitial value when uncontrolled.
onValueChange(value: string) => voidCalled when the selection changes.
namestringgenerated via useId()Native name shared by all RadioItems so browser radio grouping works without manual wiring.

RadioItem (extends InputHTMLAttributes<HTMLInputElement>)

PropTypeDefaultDescription
valuestring— (required)This item's value, compared against the group's selected value.
idstringgenerated via useId()Native id, also used as the item's registration key.
childrenReactNodefalls back to valueLabel 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;