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.

Code

// RadioGroup.tsx
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;

// RadioItem.tsx
import React, { useId, useEffect } from 'react';
import { useRadioGroupContext } from './RadioGroup';
import { AnimatePresence, easeOut, motion } from 'motion/react';
import { cn } from 'src/lib/utils';

interface RadioItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
  value: string;
  id?: string;
  children?: React.ReactNode;
  ref?: React.Ref<HTMLInputElement>;
}

const bubbleVariants = {
  initial: (direction: 'up' | 'down' | null) => ({
    opacity: 0,
    y: direction === null ? 0 : (direction === 'up' ? 6 : -6)
  }),
  animate: { 
    opacity: 1, 
    y: 0,
    transition: { ease: easeOut, duration: 0.15 }
  },
  exit: (direction: 'up' | 'down' | null) => ({
    opacity: 0,
    y: direction === null ? 0 : (direction === 'up' ? -6 : 6),
    transition: { ease: easeOut, duration: 0.15 }
  })
};

const RadioItem = ({ id, value, children, ref, ...props }: RadioItemProps) => {
  const context = useRadioGroupContext();
  const generatedLocalId = useId();
  const radioItemId = id ?? generatedLocalId;

  const isSelected = context.value === value;
  const currentIndex = context.registeredIds.indexOf(radioItemId)
  const isHovered = currentIndex !== -1 && context.hoveredIndex === currentIndex;

  useEffect(() => {
    return context.handleRegister(radioItemId)
  },[radioItemId, context.handleRegister])

  return (
    <div
      onMouseEnter={() => context.onHoverChange(currentIndex)}
      onFocus={() => context.onHoverChange(currentIndex)}
      className="flex flex-row gap-2 p-2 rounded-md items-center relative has-[:focus-visible]:ring-2"
    >
      <input
        {...props}
        type="radio"
        ref={ref}
        id={radioItemId}
        name={context.name}
        checked={isSelected}
        onChange={() => context.onValueChange(value)}
        className="absolute inset-0 z-30 opacity-0 cursor-pointer"
      />
      <div className="relative z-20 flex items-center justify-center w-4 h-4 border border-gray-300 rounded-lg cursor-pointer">
        <AnimatePresence>
          {isSelected && (
            <motion.div 
              initial={{ scale: 0.5, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={{ scale: 0.5, opacity: 0 }}
              transition={{ ease: easeOut, duration: 0.15 }}
              className="absolute z-20 w-2 h-2 bg-blue-500 rounded-lg"
            />
          )}
        </AnimatePresence>
        
        <AnimatePresence custom={context.direction}>
          {isHovered && (
            <motion.div  
              custom={context.direction}   
              variants={bubbleVariants} 
              initial="initial" 
              animate="animate"
              exit="exit"
              className="absolute z-10 w-2 h-2 bg-gray-300 rounded-lg"
            />
          )}
        </AnimatePresence>
      </div>

      <label 
        htmlFor={radioItemId}
        className={cn("font-regular z-20", isSelected ? "text-black" : "text-gray-500")}
      >
        {children || value}
      </label>
    </div>
  );
};

export default RadioItem;