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;