Motion Highlight
Versatile highlight component with smooth animations for interactive UI elements. Perfect for React applications requiring dynamic visual feedback with Next.js integration and TypeScript support.
Powered by
import { MotionHighlight } from '@/components/ui/shadcn-io/motion-highlight';const TABS = [ { value: 'tab-1', title: 'Tab 1', description: 'Tab 1 description', }, { value: 'tab-2', title: 'Tab 2', description: 'Tab 2 description', }, { value: 'tab-3', title: 'Tab 3', description: 'Tab 3 description', },];export default function MotionHighlightDemo() { return ( <div className="flex border rounded-full p-1"> <MotionHighlight defaultValue={TABS[0]?.value} className="rounded-full"> {TABS.map((tab) => ( <div key={tab.value} data-value={tab.value} className="px-3 h-8 flex items-center cursor-pointer justify-center rounded-full text-sm data-[active=true]:text-current data-[active=true]:font-medium text-muted-foreground transition-all duration-300" > {tab.title} </div> ))} </MotionHighlight> </div> );}
'use client';import * as React from 'react';import { AnimatePresence, Transition, motion } from 'motion/react';import { cn } from '@/lib/utils';type MotionHighlightMode = 'children' | 'parent';type Bounds = { top: number; left: number; width: number; height: number;};type MotionHighlightContextType<T extends string> = { mode: MotionHighlightMode; activeValue: T | null; setActiveValue: (value: T | null) => void; setBounds: (bounds: DOMRect) => void; clearBounds: () => void; id: string; hover: boolean; className?: string; activeClassName?: string; setActiveClassName: (className: string) => void; transition?: Transition; disabled?: boolean; enabled?: boolean; exitDelay?: number; forceUpdateBounds?: boolean;};const MotionHighlightContext = React.createContext< // eslint-disable-next-line @typescript-eslint/no-explicit-any MotionHighlightContextType<any> | undefined>(undefined);function useMotionHighlight<T extends string>(): MotionHighlightContextType<T> { const context = React.useContext(MotionHighlightContext); if (!context) { throw new Error( 'useMotionHighlight must be used within a MotionHighlightProvider', ); } return context as unknown as MotionHighlightContextType<T>;}type BaseMotionHighlightProps<T extends string> = { mode?: MotionHighlightMode; value?: T | null; defaultValue?: T | null; onValueChange?: (value: T | null) => void; className?: string; transition?: Transition; hover?: boolean; disabled?: boolean; enabled?: boolean; exitDelay?: number;};type ParentModeMotionHighlightProps = { boundsOffset?: Partial<Bounds>; containerClassName?: string; forceUpdateBounds?: boolean;};type ControlledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & ParentModeMotionHighlightProps & { mode: 'parent'; controlledItems: true; children: React.ReactNode; };type ControlledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & { mode?: 'children' | undefined; controlledItems: true; children: React.ReactNode; };type UncontrolledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & ParentModeMotionHighlightProps & { mode: 'parent'; controlledItems?: false; itemsClassName?: string; children: React.ReactElement | React.ReactElement[]; };type UncontrolledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & { mode?: 'children'; controlledItems?: false; itemsClassName?: string; children: React.ReactElement | React.ReactElement[]; };type MotionHighlightProps<T extends string> = React.ComponentProps<'div'> & ( | ControlledParentModeMotionHighlightProps<T> | ControlledChildrenModeMotionHighlightProps<T> | UncontrolledParentModeMotionHighlightProps<T> | UncontrolledChildrenModeMotionHighlightProps<T> );function MotionHighlight<T extends string>({ ref, ...props}: MotionHighlightProps<T>) { const { children, value, defaultValue, onValueChange, className, transition = { type: 'spring', stiffness: 350, damping: 35 }, hover = false, enabled = true, controlledItems, disabled = false, exitDelay = 0.2, mode = 'children', } = props; const localRef = React.useRef<HTMLDivElement>(null); React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); const [activeValue, setActiveValue] = React.useState<T | null>( value ?? defaultValue ?? null, ); const [boundsState, setBoundsState] = React.useState<Bounds | null>(null); const [activeClassNameState, setActiveClassNameState] = React.useState<string>(''); const safeSetActiveValue = React.useCallback( (id: T | null) => { setActiveValue((prev) => (prev === id ? prev : id)); if (id !== activeValue) onValueChange?.(id as T); }, [activeValue, onValueChange], ); const safeSetBounds = React.useCallback( (bounds: DOMRect) => { if (!localRef.current) return; const boundsOffset = (props as ParentModeMotionHighlightProps) ?.boundsOffset ?? { top: 0, left: 0, width: 0, height: 0, }; const containerRect = localRef.current.getBoundingClientRect(); const newBounds: Bounds = { top: bounds.top - containerRect.top + (boundsOffset.top ?? 0), left: bounds.left - containerRect.left + (boundsOffset.left ?? 0), width: bounds.width + (boundsOffset.width ?? 0), height: bounds.height + (boundsOffset.height ?? 0), }; setBoundsState((prev) => { if ( prev && prev.top === newBounds.top && prev.left === newBounds.left && prev.width === newBounds.width && prev.height === newBounds.height ) { return prev; } return newBounds; }); }, [props], ); const clearBounds = React.useCallback(() => { setBoundsState((prev) => (prev === null ? prev : null)); }, []); React.useEffect(() => { if (value !== undefined) setActiveValue(value); else if (defaultValue !== undefined) setActiveValue(defaultValue); }, [value, defaultValue]); const id = React.useId(); React.useEffect(() => { if (mode !== 'parent') return; const container = localRef.current; if (!container) return; const onScroll = () => { if (!activeValue) return; const activeEl = container.querySelector<HTMLElement>( `[data-value="${activeValue}"][data-highlight="true"]`, ); if (activeEl) safeSetBounds(activeEl.getBoundingClientRect()); }; container.addEventListener('scroll', onScroll, { passive: true }); return () => container.removeEventListener('scroll', onScroll); }, [mode, activeValue, safeSetBounds]); const render = React.useCallback( (children: React.ReactNode) => { if (mode === 'parent') { return ( <div ref={localRef} data-slot="motion-highlight-container" className={cn( 'relative', (props as ParentModeMotionHighlightProps)?.containerClassName, )} > <AnimatePresence initial={false}> {boundsState && ( <motion.div data-slot="motion-highlight" animate={{ top: boundsState.top, left: boundsState.left, width: boundsState.width, height: boundsState.height, opacity: 1, }} initial={{ top: boundsState.top, left: boundsState.left, width: boundsState.width, height: boundsState.height, opacity: 0, }} exit={{ opacity: 0, transition: { ...transition, delay: (transition?.delay ?? 0) + (exitDelay ?? 0), }, }} transition={transition} className={cn( 'absolute bg-muted z-0', className, activeClassNameState, )} /> )} </AnimatePresence> {children} </div> ); } return children; }, [ mode, props, boundsState, transition, exitDelay, className, activeClassNameState, ], ); return ( <MotionHighlightContext.Provider value={{ mode, activeValue, setActiveValue: safeSetActiveValue, id, hover, className, transition, disabled, enabled, exitDelay, setBounds: safeSetBounds, clearBounds, activeClassName: activeClassNameState, setActiveClassName: setActiveClassNameState, forceUpdateBounds: (props as ParentModeMotionHighlightProps) ?.forceUpdateBounds, }} > {enabled ? controlledItems ? render(children) : render( React.Children.map(children, (child, index) => ( <MotionHighlightItem key={index} className={props?.itemsClassName} > {child} </MotionHighlightItem> )), ) : children} </MotionHighlightContext.Provider> );}function getNonOverridingDataAttributes( element: React.ReactElement, dataAttributes: Record<string, unknown>,): Record<string, unknown> { return Object.keys(dataAttributes).reduce<Record<string, unknown>>( (acc, key) => { if ((element.props as Record<string, unknown>)[key] === undefined) { acc[key] = dataAttributes[key]; } return acc; }, {}, );}type ExtendedChildProps = React.ComponentProps<'div'> & { id?: string; ref?: React.Ref<HTMLElement>; 'data-active'?: string; 'data-value'?: string; 'data-disabled'?: boolean; 'data-highlight'?: boolean; 'data-slot'?: string;};type MotionHighlightItemProps = React.ComponentProps<'div'> & { children: React.ReactElement; id?: string; value?: string; className?: string; transition?: Transition; activeClassName?: string; disabled?: boolean; exitDelay?: number; asChild?: boolean; forceUpdateBounds?: boolean;};function MotionHighlightItem({ ref, children, id, value, className, transition, disabled = false, activeClassName, exitDelay, asChild = false, forceUpdateBounds, ...props}: MotionHighlightItemProps) { const itemId = React.useId(); const { activeValue, setActiveValue, mode, setBounds, clearBounds, hover, enabled, className: contextClassName, transition: contextTransition, id: contextId, disabled: contextDisabled, exitDelay: contextExitDelay, forceUpdateBounds: contextForceUpdateBounds, setActiveClassName, } = useMotionHighlight(); const element = children as React.ReactElement<ExtendedChildProps>; const childValue = id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId; const isActive = activeValue === childValue; const isDisabled = disabled === undefined ? contextDisabled : disabled; const itemTransition = transition ?? contextTransition; const localRef = React.useRef<HTMLDivElement>(null); React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); React.useEffect(() => { if (mode !== 'parent') return; let rafId: number; let previousBounds: Bounds | null = null; const shouldUpdateBounds = forceUpdateBounds === true || (contextForceUpdateBounds && forceUpdateBounds !== false); const updateBounds = () => { if (!localRef.current) return; const bounds = localRef.current.getBoundingClientRect(); if (shouldUpdateBounds) { if ( previousBounds && previousBounds.top === bounds.top && previousBounds.left === bounds.left && previousBounds.width === bounds.width && previousBounds.height === bounds.height ) { rafId = requestAnimationFrame(updateBounds); return; } previousBounds = bounds; rafId = requestAnimationFrame(updateBounds); } setBounds(bounds); }; if (isActive) { updateBounds(); setActiveClassName(activeClassName ?? ''); } else if (!activeValue) clearBounds(); if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId); }, [ mode, isActive, activeValue, setBounds, clearBounds, activeClassName, setActiveClassName, forceUpdateBounds, contextForceUpdateBounds, ]); if (!React.isValidElement(children)) return children; const dataAttributes = { 'data-active': isActive ? 'true' : 'false', 'aria-selected': isActive, 'data-disabled': isDisabled, 'data-value': childValue, 'data-highlight': true, }; const commonHandlers = hover ? { onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(childValue); element.props.onMouseEnter?.(e); }, onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(null); element.props.onMouseLeave?.(e); }, } : { onClick: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(childValue); element.props.onClick?.(e); }, }; if (asChild) { if (mode === 'children') { return React.cloneElement( element, { key: childValue, ref: localRef, className: cn('relative', element.props.className), ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item-container', }), ...commonHandlers, ...props, }, <> <AnimatePresence initial={false}> {isActive && !isDisabled && ( <motion.div layoutId={`transition-background-${contextId}`} data-slot="motion-highlight" className={cn( 'absolute inset-0 bg-muted z-0', contextClassName, activeClassName, )} transition={itemTransition} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { ...itemTransition, delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0), }, }} {...dataAttributes} /> )} </AnimatePresence> <div data-slot="motion-highlight-item" className={cn('relative z-[1]', className)} {...dataAttributes} > {children} </div> </>, ); } return React.cloneElement(element, { ref: localRef, ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item', }), ...commonHandlers, }); } return enabled ? ( <div key={childValue} ref={localRef} data-slot="motion-highlight-item-container" className={cn(mode === 'children' && 'relative', className)} {...dataAttributes} {...props} {...commonHandlers} > {mode === 'children' && ( <AnimatePresence initial={false}> {isActive && !isDisabled && ( <motion.div layoutId={`transition-background-${contextId}`} data-slot="motion-highlight" className={cn( 'absolute inset-0 bg-muted z-0', contextClassName, activeClassName, )} transition={itemTransition} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { ...itemTransition, delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0), }, }} {...dataAttributes} /> )} </AnimatePresence> )} {React.cloneElement(element, { className: cn('relative z-[1]', element.props.className), ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item', }), })} </div> ) : ( children );}export { MotionHighlight, MotionHighlightItem, useMotionHighlight, type MotionHighlightProps, type MotionHighlightItemProps,};
Installation
npx shadcn@latest add https://www.shadcn.io/registry/motion-highlight.json
npx shadcn@latest add https://www.shadcn.io/registry/motion-highlight.json
pnpm dlx shadcn@latest add https://www.shadcn.io/registry/motion-highlight.json
bunx shadcn@latest add https://www.shadcn.io/registry/motion-highlight.json
Features
- Two rendering modes - Children mode with layoutId technique and parent mode for z-index control
- Hover and click interactions with configurable event handling
- Controlled and uncontrolled modes for flexible state management
- Bounds offsetting for fine-tuning highlight positioning
- TypeScript support with complete generic type definitions
- AnimatePresence support with configurable exit delays
Examples
Hover Mode
import { MotionHighlight } from '@/components/ui/shadcn-io/motion-highlight';const TABS = [ { value: 'tab-1', title: 'Tab 1', description: 'Tab 1 description', }, { value: 'tab-2', title: 'Tab 2', description: 'Tab 2 description', }, { value: 'tab-3', title: 'Tab 3', description: 'Tab 3 description', },];export default function MotionHighlightHoverDemo() { return ( <div className="flex border rounded-full p-1"> <MotionHighlight hover className="rounded-full"> {TABS.map((tab) => ( <div key={tab.value} data-value={tab.value} className="px-3 h-8 flex items-center cursor-pointer justify-center rounded-full text-sm data-[active=true]:text-current data-[active=true]:font-medium text-muted-foreground transition-all duration-300" > {tab.title} </div> ))} </MotionHighlight> </div> );}
'use client';import * as React from 'react';import { AnimatePresence, Transition, motion } from 'motion/react';import { cn } from '@/lib/utils';type MotionHighlightMode = 'children' | 'parent';type Bounds = { top: number; left: number; width: number; height: number;};type MotionHighlightContextType<T extends string> = { mode: MotionHighlightMode; activeValue: T | null; setActiveValue: (value: T | null) => void; setBounds: (bounds: DOMRect) => void; clearBounds: () => void; id: string; hover: boolean; className?: string; activeClassName?: string; setActiveClassName: (className: string) => void; transition?: Transition; disabled?: boolean; enabled?: boolean; exitDelay?: number; forceUpdateBounds?: boolean;};const MotionHighlightContext = React.createContext< // eslint-disable-next-line @typescript-eslint/no-explicit-any MotionHighlightContextType<any> | undefined>(undefined);function useMotionHighlight<T extends string>(): MotionHighlightContextType<T> { const context = React.useContext(MotionHighlightContext); if (!context) { throw new Error( 'useMotionHighlight must be used within a MotionHighlightProvider', ); } return context as unknown as MotionHighlightContextType<T>;}type BaseMotionHighlightProps<T extends string> = { mode?: MotionHighlightMode; value?: T | null; defaultValue?: T | null; onValueChange?: (value: T | null) => void; className?: string; transition?: Transition; hover?: boolean; disabled?: boolean; enabled?: boolean; exitDelay?: number;};type ParentModeMotionHighlightProps = { boundsOffset?: Partial<Bounds>; containerClassName?: string; forceUpdateBounds?: boolean;};type ControlledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & ParentModeMotionHighlightProps & { mode: 'parent'; controlledItems: true; children: React.ReactNode; };type ControlledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & { mode?: 'children' | undefined; controlledItems: true; children: React.ReactNode; };type UncontrolledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & ParentModeMotionHighlightProps & { mode: 'parent'; controlledItems?: false; itemsClassName?: string; children: React.ReactElement | React.ReactElement[]; };type UncontrolledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & { mode?: 'children'; controlledItems?: false; itemsClassName?: string; children: React.ReactElement | React.ReactElement[]; };type MotionHighlightProps<T extends string> = React.ComponentProps<'div'> & ( | ControlledParentModeMotionHighlightProps<T> | ControlledChildrenModeMotionHighlightProps<T> | UncontrolledParentModeMotionHighlightProps<T> | UncontrolledChildrenModeMotionHighlightProps<T> );function MotionHighlight<T extends string>({ ref, ...props}: MotionHighlightProps<T>) { const { children, value, defaultValue, onValueChange, className, transition = { type: 'spring', stiffness: 350, damping: 35 }, hover = false, enabled = true, controlledItems, disabled = false, exitDelay = 0.2, mode = 'children', } = props; const localRef = React.useRef<HTMLDivElement>(null); React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); const [activeValue, setActiveValue] = React.useState<T | null>( value ?? defaultValue ?? null, ); const [boundsState, setBoundsState] = React.useState<Bounds | null>(null); const [activeClassNameState, setActiveClassNameState] = React.useState<string>(''); const safeSetActiveValue = React.useCallback( (id: T | null) => { setActiveValue((prev) => (prev === id ? prev : id)); if (id !== activeValue) onValueChange?.(id as T); }, [activeValue, onValueChange], ); const safeSetBounds = React.useCallback( (bounds: DOMRect) => { if (!localRef.current) return; const boundsOffset = (props as ParentModeMotionHighlightProps) ?.boundsOffset ?? { top: 0, left: 0, width: 0, height: 0, }; const containerRect = localRef.current.getBoundingClientRect(); const newBounds: Bounds = { top: bounds.top - containerRect.top + (boundsOffset.top ?? 0), left: bounds.left - containerRect.left + (boundsOffset.left ?? 0), width: bounds.width + (boundsOffset.width ?? 0), height: bounds.height + (boundsOffset.height ?? 0), }; setBoundsState((prev) => { if ( prev && prev.top === newBounds.top && prev.left === newBounds.left && prev.width === newBounds.width && prev.height === newBounds.height ) { return prev; } return newBounds; }); }, [props], ); const clearBounds = React.useCallback(() => { setBoundsState((prev) => (prev === null ? prev : null)); }, []); React.useEffect(() => { if (value !== undefined) setActiveValue(value); else if (defaultValue !== undefined) setActiveValue(defaultValue); }, [value, defaultValue]); const id = React.useId(); React.useEffect(() => { if (mode !== 'parent') return; const container = localRef.current; if (!container) return; const onScroll = () => { if (!activeValue) return; const activeEl = container.querySelector<HTMLElement>( `[data-value="${activeValue}"][data-highlight="true"]`, ); if (activeEl) safeSetBounds(activeEl.getBoundingClientRect()); }; container.addEventListener('scroll', onScroll, { passive: true }); return () => container.removeEventListener('scroll', onScroll); }, [mode, activeValue, safeSetBounds]); const render = React.useCallback( (children: React.ReactNode) => { if (mode === 'parent') { return ( <div ref={localRef} data-slot="motion-highlight-container" className={cn( 'relative', (props as ParentModeMotionHighlightProps)?.containerClassName, )} > <AnimatePresence initial={false}> {boundsState && ( <motion.div data-slot="motion-highlight" animate={{ top: boundsState.top, left: boundsState.left, width: boundsState.width, height: boundsState.height, opacity: 1, }} initial={{ top: boundsState.top, left: boundsState.left, width: boundsState.width, height: boundsState.height, opacity: 0, }} exit={{ opacity: 0, transition: { ...transition, delay: (transition?.delay ?? 0) + (exitDelay ?? 0), }, }} transition={transition} className={cn( 'absolute bg-muted z-0', className, activeClassNameState, )} /> )} </AnimatePresence> {children} </div> ); } return children; }, [ mode, props, boundsState, transition, exitDelay, className, activeClassNameState, ], ); return ( <MotionHighlightContext.Provider value={{ mode, activeValue, setActiveValue: safeSetActiveValue, id, hover, className, transition, disabled, enabled, exitDelay, setBounds: safeSetBounds, clearBounds, activeClassName: activeClassNameState, setActiveClassName: setActiveClassNameState, forceUpdateBounds: (props as ParentModeMotionHighlightProps) ?.forceUpdateBounds, }} > {enabled ? controlledItems ? render(children) : render( React.Children.map(children, (child, index) => ( <MotionHighlightItem key={index} className={props?.itemsClassName} > {child} </MotionHighlightItem> )), ) : children} </MotionHighlightContext.Provider> );}function getNonOverridingDataAttributes( element: React.ReactElement, dataAttributes: Record<string, unknown>,): Record<string, unknown> { return Object.keys(dataAttributes).reduce<Record<string, unknown>>( (acc, key) => { if ((element.props as Record<string, unknown>)[key] === undefined) { acc[key] = dataAttributes[key]; } return acc; }, {}, );}type ExtendedChildProps = React.ComponentProps<'div'> & { id?: string; ref?: React.Ref<HTMLElement>; 'data-active'?: string; 'data-value'?: string; 'data-disabled'?: boolean; 'data-highlight'?: boolean; 'data-slot'?: string;};type MotionHighlightItemProps = React.ComponentProps<'div'> & { children: React.ReactElement; id?: string; value?: string; className?: string; transition?: Transition; activeClassName?: string; disabled?: boolean; exitDelay?: number; asChild?: boolean; forceUpdateBounds?: boolean;};function MotionHighlightItem({ ref, children, id, value, className, transition, disabled = false, activeClassName, exitDelay, asChild = false, forceUpdateBounds, ...props}: MotionHighlightItemProps) { const itemId = React.useId(); const { activeValue, setActiveValue, mode, setBounds, clearBounds, hover, enabled, className: contextClassName, transition: contextTransition, id: contextId, disabled: contextDisabled, exitDelay: contextExitDelay, forceUpdateBounds: contextForceUpdateBounds, setActiveClassName, } = useMotionHighlight(); const element = children as React.ReactElement<ExtendedChildProps>; const childValue = id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId; const isActive = activeValue === childValue; const isDisabled = disabled === undefined ? contextDisabled : disabled; const itemTransition = transition ?? contextTransition; const localRef = React.useRef<HTMLDivElement>(null); React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); React.useEffect(() => { if (mode !== 'parent') return; let rafId: number; let previousBounds: Bounds | null = null; const shouldUpdateBounds = forceUpdateBounds === true || (contextForceUpdateBounds && forceUpdateBounds !== false); const updateBounds = () => { if (!localRef.current) return; const bounds = localRef.current.getBoundingClientRect(); if (shouldUpdateBounds) { if ( previousBounds && previousBounds.top === bounds.top && previousBounds.left === bounds.left && previousBounds.width === bounds.width && previousBounds.height === bounds.height ) { rafId = requestAnimationFrame(updateBounds); return; } previousBounds = bounds; rafId = requestAnimationFrame(updateBounds); } setBounds(bounds); }; if (isActive) { updateBounds(); setActiveClassName(activeClassName ?? ''); } else if (!activeValue) clearBounds(); if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId); }, [ mode, isActive, activeValue, setBounds, clearBounds, activeClassName, setActiveClassName, forceUpdateBounds, contextForceUpdateBounds, ]); if (!React.isValidElement(children)) return children; const dataAttributes = { 'data-active': isActive ? 'true' : 'false', 'aria-selected': isActive, 'data-disabled': isDisabled, 'data-value': childValue, 'data-highlight': true, }; const commonHandlers = hover ? { onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(childValue); element.props.onMouseEnter?.(e); }, onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(null); element.props.onMouseLeave?.(e); }, } : { onClick: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(childValue); element.props.onClick?.(e); }, }; if (asChild) { if (mode === 'children') { return React.cloneElement( element, { key: childValue, ref: localRef, className: cn('relative', element.props.className), ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item-container', }), ...commonHandlers, ...props, }, <> <AnimatePresence initial={false}> {isActive && !isDisabled && ( <motion.div layoutId={`transition-background-${contextId}`} data-slot="motion-highlight" className={cn( 'absolute inset-0 bg-muted z-0', contextClassName, activeClassName, )} transition={itemTransition} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { ...itemTransition, delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0), }, }} {...dataAttributes} /> )} </AnimatePresence> <div data-slot="motion-highlight-item" className={cn('relative z-[1]', className)} {...dataAttributes} > {children} </div> </>, ); } return React.cloneElement(element, { ref: localRef, ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item', }), ...commonHandlers, }); } return enabled ? ( <div key={childValue} ref={localRef} data-slot="motion-highlight-item-container" className={cn(mode === 'children' && 'relative', className)} {...dataAttributes} {...props} {...commonHandlers} > {mode === 'children' && ( <AnimatePresence initial={false}> {isActive && !isDisabled && ( <motion.div layoutId={`transition-background-${contextId}`} data-slot="motion-highlight" className={cn( 'absolute inset-0 bg-muted z-0', contextClassName, activeClassName, )} transition={itemTransition} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { ...itemTransition, delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0), }, }} {...dataAttributes} /> )} </AnimatePresence> )} {React.cloneElement(element, { className: cn('relative z-[1]', element.props.className), ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item', }), })} </div> ) : ( children );}export { MotionHighlight, MotionHighlightItem, useMotionHighlight, type MotionHighlightProps, type MotionHighlightItemProps,};
Activate highlighting on hover instead of click for more responsive interactions.
Parent Mode
import { MotionHighlight } from '@/components/ui/shadcn-io/motion-highlight';const TABS = [ { value: 'tab-1', title: 'Tab 1', description: 'Tab 1 description', }, { value: 'tab-2', title: 'Tab 2', description: 'Tab 2 description', }, { value: 'tab-3', title: 'Tab 3', description: 'Tab 3 description', },];export default function MotionHighlightParentDemo() { return ( <MotionHighlight hover mode="parent" containerClassName="flex border rounded-full p-1" className="rounded-full" boundsOffset={{ top: -1, left: -1 }} // we have to add an offset of the same size as the border when we set a border > {TABS.map((tab) => ( <div key={tab.value} data-value={tab.value} className="px-3 h-8 flex items-center cursor-pointer justify-center rounded-full text-sm data-[active=true]:text-current data-[active=true]:font-medium text-muted-foreground transition-all duration-300" > {tab.title} </div> ))} </MotionHighlight> );}
'use client';import * as React from 'react';import { AnimatePresence, Transition, motion } from 'motion/react';import { cn } from '@/lib/utils';type MotionHighlightMode = 'children' | 'parent';type Bounds = { top: number; left: number; width: number; height: number;};type MotionHighlightContextType<T extends string> = { mode: MotionHighlightMode; activeValue: T | null; setActiveValue: (value: T | null) => void; setBounds: (bounds: DOMRect) => void; clearBounds: () => void; id: string; hover: boolean; className?: string; activeClassName?: string; setActiveClassName: (className: string) => void; transition?: Transition; disabled?: boolean; enabled?: boolean; exitDelay?: number; forceUpdateBounds?: boolean;};const MotionHighlightContext = React.createContext< // eslint-disable-next-line @typescript-eslint/no-explicit-any MotionHighlightContextType<any> | undefined>(undefined);function useMotionHighlight<T extends string>(): MotionHighlightContextType<T> { const context = React.useContext(MotionHighlightContext); if (!context) { throw new Error( 'useMotionHighlight must be used within a MotionHighlightProvider', ); } return context as unknown as MotionHighlightContextType<T>;}type BaseMotionHighlightProps<T extends string> = { mode?: MotionHighlightMode; value?: T | null; defaultValue?: T | null; onValueChange?: (value: T | null) => void; className?: string; transition?: Transition; hover?: boolean; disabled?: boolean; enabled?: boolean; exitDelay?: number;};type ParentModeMotionHighlightProps = { boundsOffset?: Partial<Bounds>; containerClassName?: string; forceUpdateBounds?: boolean;};type ControlledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & ParentModeMotionHighlightProps & { mode: 'parent'; controlledItems: true; children: React.ReactNode; };type ControlledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & { mode?: 'children' | undefined; controlledItems: true; children: React.ReactNode; };type UncontrolledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & ParentModeMotionHighlightProps & { mode: 'parent'; controlledItems?: false; itemsClassName?: string; children: React.ReactElement | React.ReactElement[]; };type UncontrolledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & { mode?: 'children'; controlledItems?: false; itemsClassName?: string; children: React.ReactElement | React.ReactElement[]; };type MotionHighlightProps<T extends string> = React.ComponentProps<'div'> & ( | ControlledParentModeMotionHighlightProps<T> | ControlledChildrenModeMotionHighlightProps<T> | UncontrolledParentModeMotionHighlightProps<T> | UncontrolledChildrenModeMotionHighlightProps<T> );function MotionHighlight<T extends string>({ ref, ...props}: MotionHighlightProps<T>) { const { children, value, defaultValue, onValueChange, className, transition = { type: 'spring', stiffness: 350, damping: 35 }, hover = false, enabled = true, controlledItems, disabled = false, exitDelay = 0.2, mode = 'children', } = props; const localRef = React.useRef<HTMLDivElement>(null); React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); const [activeValue, setActiveValue] = React.useState<T | null>( value ?? defaultValue ?? null, ); const [boundsState, setBoundsState] = React.useState<Bounds | null>(null); const [activeClassNameState, setActiveClassNameState] = React.useState<string>(''); const safeSetActiveValue = React.useCallback( (id: T | null) => { setActiveValue((prev) => (prev === id ? prev : id)); if (id !== activeValue) onValueChange?.(id as T); }, [activeValue, onValueChange], ); const safeSetBounds = React.useCallback( (bounds: DOMRect) => { if (!localRef.current) return; const boundsOffset = (props as ParentModeMotionHighlightProps) ?.boundsOffset ?? { top: 0, left: 0, width: 0, height: 0, }; const containerRect = localRef.current.getBoundingClientRect(); const newBounds: Bounds = { top: bounds.top - containerRect.top + (boundsOffset.top ?? 0), left: bounds.left - containerRect.left + (boundsOffset.left ?? 0), width: bounds.width + (boundsOffset.width ?? 0), height: bounds.height + (boundsOffset.height ?? 0), }; setBoundsState((prev) => { if ( prev && prev.top === newBounds.top && prev.left === newBounds.left && prev.width === newBounds.width && prev.height === newBounds.height ) { return prev; } return newBounds; }); }, [props], ); const clearBounds = React.useCallback(() => { setBoundsState((prev) => (prev === null ? prev : null)); }, []); React.useEffect(() => { if (value !== undefined) setActiveValue(value); else if (defaultValue !== undefined) setActiveValue(defaultValue); }, [value, defaultValue]); const id = React.useId(); React.useEffect(() => { if (mode !== 'parent') return; const container = localRef.current; if (!container) return; const onScroll = () => { if (!activeValue) return; const activeEl = container.querySelector<HTMLElement>( `[data-value="${activeValue}"][data-highlight="true"]`, ); if (activeEl) safeSetBounds(activeEl.getBoundingClientRect()); }; container.addEventListener('scroll', onScroll, { passive: true }); return () => container.removeEventListener('scroll', onScroll); }, [mode, activeValue, safeSetBounds]); const render = React.useCallback( (children: React.ReactNode) => { if (mode === 'parent') { return ( <div ref={localRef} data-slot="motion-highlight-container" className={cn( 'relative', (props as ParentModeMotionHighlightProps)?.containerClassName, )} > <AnimatePresence initial={false}> {boundsState && ( <motion.div data-slot="motion-highlight" animate={{ top: boundsState.top, left: boundsState.left, width: boundsState.width, height: boundsState.height, opacity: 1, }} initial={{ top: boundsState.top, left: boundsState.left, width: boundsState.width, height: boundsState.height, opacity: 0, }} exit={{ opacity: 0, transition: { ...transition, delay: (transition?.delay ?? 0) + (exitDelay ?? 0), }, }} transition={transition} className={cn( 'absolute bg-muted z-0', className, activeClassNameState, )} /> )} </AnimatePresence> {children} </div> ); } return children; }, [ mode, props, boundsState, transition, exitDelay, className, activeClassNameState, ], ); return ( <MotionHighlightContext.Provider value={{ mode, activeValue, setActiveValue: safeSetActiveValue, id, hover, className, transition, disabled, enabled, exitDelay, setBounds: safeSetBounds, clearBounds, activeClassName: activeClassNameState, setActiveClassName: setActiveClassNameState, forceUpdateBounds: (props as ParentModeMotionHighlightProps) ?.forceUpdateBounds, }} > {enabled ? controlledItems ? render(children) : render( React.Children.map(children, (child, index) => ( <MotionHighlightItem key={index} className={props?.itemsClassName} > {child} </MotionHighlightItem> )), ) : children} </MotionHighlightContext.Provider> );}function getNonOverridingDataAttributes( element: React.ReactElement, dataAttributes: Record<string, unknown>,): Record<string, unknown> { return Object.keys(dataAttributes).reduce<Record<string, unknown>>( (acc, key) => { if ((element.props as Record<string, unknown>)[key] === undefined) { acc[key] = dataAttributes[key]; } return acc; }, {}, );}type ExtendedChildProps = React.ComponentProps<'div'> & { id?: string; ref?: React.Ref<HTMLElement>; 'data-active'?: string; 'data-value'?: string; 'data-disabled'?: boolean; 'data-highlight'?: boolean; 'data-slot'?: string;};type MotionHighlightItemProps = React.ComponentProps<'div'> & { children: React.ReactElement; id?: string; value?: string; className?: string; transition?: Transition; activeClassName?: string; disabled?: boolean; exitDelay?: number; asChild?: boolean; forceUpdateBounds?: boolean;};function MotionHighlightItem({ ref, children, id, value, className, transition, disabled = false, activeClassName, exitDelay, asChild = false, forceUpdateBounds, ...props}: MotionHighlightItemProps) { const itemId = React.useId(); const { activeValue, setActiveValue, mode, setBounds, clearBounds, hover, enabled, className: contextClassName, transition: contextTransition, id: contextId, disabled: contextDisabled, exitDelay: contextExitDelay, forceUpdateBounds: contextForceUpdateBounds, setActiveClassName, } = useMotionHighlight(); const element = children as React.ReactElement<ExtendedChildProps>; const childValue = id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId; const isActive = activeValue === childValue; const isDisabled = disabled === undefined ? contextDisabled : disabled; const itemTransition = transition ?? contextTransition; const localRef = React.useRef<HTMLDivElement>(null); React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); React.useEffect(() => { if (mode !== 'parent') return; let rafId: number; let previousBounds: Bounds | null = null; const shouldUpdateBounds = forceUpdateBounds === true || (contextForceUpdateBounds && forceUpdateBounds !== false); const updateBounds = () => { if (!localRef.current) return; const bounds = localRef.current.getBoundingClientRect(); if (shouldUpdateBounds) { if ( previousBounds && previousBounds.top === bounds.top && previousBounds.left === bounds.left && previousBounds.width === bounds.width && previousBounds.height === bounds.height ) { rafId = requestAnimationFrame(updateBounds); return; } previousBounds = bounds; rafId = requestAnimationFrame(updateBounds); } setBounds(bounds); }; if (isActive) { updateBounds(); setActiveClassName(activeClassName ?? ''); } else if (!activeValue) clearBounds(); if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId); }, [ mode, isActive, activeValue, setBounds, clearBounds, activeClassName, setActiveClassName, forceUpdateBounds, contextForceUpdateBounds, ]); if (!React.isValidElement(children)) return children; const dataAttributes = { 'data-active': isActive ? 'true' : 'false', 'aria-selected': isActive, 'data-disabled': isDisabled, 'data-value': childValue, 'data-highlight': true, }; const commonHandlers = hover ? { onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(childValue); element.props.onMouseEnter?.(e); }, onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(null); element.props.onMouseLeave?.(e); }, } : { onClick: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(childValue); element.props.onClick?.(e); }, }; if (asChild) { if (mode === 'children') { return React.cloneElement( element, { key: childValue, ref: localRef, className: cn('relative', element.props.className), ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item-container', }), ...commonHandlers, ...props, }, <> <AnimatePresence initial={false}> {isActive && !isDisabled && ( <motion.div layoutId={`transition-background-${contextId}`} data-slot="motion-highlight" className={cn( 'absolute inset-0 bg-muted z-0', contextClassName, activeClassName, )} transition={itemTransition} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { ...itemTransition, delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0), }, }} {...dataAttributes} /> )} </AnimatePresence> <div data-slot="motion-highlight-item" className={cn('relative z-[1]', className)} {...dataAttributes} > {children} </div> </>, ); } return React.cloneElement(element, { ref: localRef, ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item', }), ...commonHandlers, }); } return enabled ? ( <div key={childValue} ref={localRef} data-slot="motion-highlight-item-container" className={cn(mode === 'children' && 'relative', className)} {...dataAttributes} {...props} {...commonHandlers} > {mode === 'children' && ( <AnimatePresence initial={false}> {isActive && !isDisabled && ( <motion.div layoutId={`transition-background-${contextId}`} data-slot="motion-highlight" className={cn( 'absolute inset-0 bg-muted z-0', contextClassName, activeClassName, )} transition={itemTransition} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { ...itemTransition, delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0), }, }} {...dataAttributes} /> )} </AnimatePresence> )} {React.cloneElement(element, { className: cn('relative z-[1]', element.props.className), ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item', }), })} </div> ) : ( children );}export { MotionHighlight, MotionHighlightItem, useMotionHighlight, type MotionHighlightProps, type MotionHighlightItemProps,};
Use parent mode to avoid z-index issues by positioning the highlight in the container.
Card Grid
import { Blocks, BringToFront, GitPullRequest } from 'lucide-react';import { MotionHighlight } from '@/components/ui/shadcn-io/motion-highlight';const CARDS = [ { value: '1', icon: BringToFront, title: 'Animated Components', description: 'Beautiful Motion-animated components for dynamic websites.', }, { value: '2', icon: GitPullRequest, title: 'Open Source', description: 'A project built for the dev community with the dev community.', }, { value: '3', icon: Blocks, title: 'Complementary to Shadcn UI', description: 'The components are designed to be used with Shadcn UI components.', }, { value: '4', icon: Blocks, title: 'Component Distribution', description: 'Install the components in your project and modify them as you wish.', },];export default function MotionHighlightCardsDemo() { return ( <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <MotionHighlight hover className="rounded-xl"> {CARDS.map((card) => ( <div key={card.value} data-value={card.value}> <div className="p-4 flex flex-col border rounded-xl"> <div className="flex items-center justify-around size-10 rounded-lg bg-blue-500/10 mb-2"> <card.icon className="size-5 text-blue-500" /> </div> <p className="text-base font-medium mb-1">{card.title}</p> <p className="text-sm text-muted-foreground"> {card.description} </p> </div> </div> ))} </MotionHighlight> </div> );}
'use client';import * as React from 'react';import { AnimatePresence, Transition, motion } from 'motion/react';import { cn } from '@/lib/utils';type MotionHighlightMode = 'children' | 'parent';type Bounds = { top: number; left: number; width: number; height: number;};type MotionHighlightContextType<T extends string> = { mode: MotionHighlightMode; activeValue: T | null; setActiveValue: (value: T | null) => void; setBounds: (bounds: DOMRect) => void; clearBounds: () => void; id: string; hover: boolean; className?: string; activeClassName?: string; setActiveClassName: (className: string) => void; transition?: Transition; disabled?: boolean; enabled?: boolean; exitDelay?: number; forceUpdateBounds?: boolean;};const MotionHighlightContext = React.createContext< // eslint-disable-next-line @typescript-eslint/no-explicit-any MotionHighlightContextType<any> | undefined>(undefined);function useMotionHighlight<T extends string>(): MotionHighlightContextType<T> { const context = React.useContext(MotionHighlightContext); if (!context) { throw new Error( 'useMotionHighlight must be used within a MotionHighlightProvider', ); } return context as unknown as MotionHighlightContextType<T>;}type BaseMotionHighlightProps<T extends string> = { mode?: MotionHighlightMode; value?: T | null; defaultValue?: T | null; onValueChange?: (value: T | null) => void; className?: string; transition?: Transition; hover?: boolean; disabled?: boolean; enabled?: boolean; exitDelay?: number;};type ParentModeMotionHighlightProps = { boundsOffset?: Partial<Bounds>; containerClassName?: string; forceUpdateBounds?: boolean;};type ControlledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & ParentModeMotionHighlightProps & { mode: 'parent'; controlledItems: true; children: React.ReactNode; };type ControlledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & { mode?: 'children' | undefined; controlledItems: true; children: React.ReactNode; };type UncontrolledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & ParentModeMotionHighlightProps & { mode: 'parent'; controlledItems?: false; itemsClassName?: string; children: React.ReactElement | React.ReactElement[]; };type UncontrolledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & { mode?: 'children'; controlledItems?: false; itemsClassName?: string; children: React.ReactElement | React.ReactElement[]; };type MotionHighlightProps<T extends string> = React.ComponentProps<'div'> & ( | ControlledParentModeMotionHighlightProps<T> | ControlledChildrenModeMotionHighlightProps<T> | UncontrolledParentModeMotionHighlightProps<T> | UncontrolledChildrenModeMotionHighlightProps<T> );function MotionHighlight<T extends string>({ ref, ...props}: MotionHighlightProps<T>) { const { children, value, defaultValue, onValueChange, className, transition = { type: 'spring', stiffness: 350, damping: 35 }, hover = false, enabled = true, controlledItems, disabled = false, exitDelay = 0.2, mode = 'children', } = props; const localRef = React.useRef<HTMLDivElement>(null); React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); const [activeValue, setActiveValue] = React.useState<T | null>( value ?? defaultValue ?? null, ); const [boundsState, setBoundsState] = React.useState<Bounds | null>(null); const [activeClassNameState, setActiveClassNameState] = React.useState<string>(''); const safeSetActiveValue = React.useCallback( (id: T | null) => { setActiveValue((prev) => (prev === id ? prev : id)); if (id !== activeValue) onValueChange?.(id as T); }, [activeValue, onValueChange], ); const safeSetBounds = React.useCallback( (bounds: DOMRect) => { if (!localRef.current) return; const boundsOffset = (props as ParentModeMotionHighlightProps) ?.boundsOffset ?? { top: 0, left: 0, width: 0, height: 0, }; const containerRect = localRef.current.getBoundingClientRect(); const newBounds: Bounds = { top: bounds.top - containerRect.top + (boundsOffset.top ?? 0), left: bounds.left - containerRect.left + (boundsOffset.left ?? 0), width: bounds.width + (boundsOffset.width ?? 0), height: bounds.height + (boundsOffset.height ?? 0), }; setBoundsState((prev) => { if ( prev && prev.top === newBounds.top && prev.left === newBounds.left && prev.width === newBounds.width && prev.height === newBounds.height ) { return prev; } return newBounds; }); }, [props], ); const clearBounds = React.useCallback(() => { setBoundsState((prev) => (prev === null ? prev : null)); }, []); React.useEffect(() => { if (value !== undefined) setActiveValue(value); else if (defaultValue !== undefined) setActiveValue(defaultValue); }, [value, defaultValue]); const id = React.useId(); React.useEffect(() => { if (mode !== 'parent') return; const container = localRef.current; if (!container) return; const onScroll = () => { if (!activeValue) return; const activeEl = container.querySelector<HTMLElement>( `[data-value="${activeValue}"][data-highlight="true"]`, ); if (activeEl) safeSetBounds(activeEl.getBoundingClientRect()); }; container.addEventListener('scroll', onScroll, { passive: true }); return () => container.removeEventListener('scroll', onScroll); }, [mode, activeValue, safeSetBounds]); const render = React.useCallback( (children: React.ReactNode) => { if (mode === 'parent') { return ( <div ref={localRef} data-slot="motion-highlight-container" className={cn( 'relative', (props as ParentModeMotionHighlightProps)?.containerClassName, )} > <AnimatePresence initial={false}> {boundsState && ( <motion.div data-slot="motion-highlight" animate={{ top: boundsState.top, left: boundsState.left, width: boundsState.width, height: boundsState.height, opacity: 1, }} initial={{ top: boundsState.top, left: boundsState.left, width: boundsState.width, height: boundsState.height, opacity: 0, }} exit={{ opacity: 0, transition: { ...transition, delay: (transition?.delay ?? 0) + (exitDelay ?? 0), }, }} transition={transition} className={cn( 'absolute bg-muted z-0', className, activeClassNameState, )} /> )} </AnimatePresence> {children} </div> ); } return children; }, [ mode, props, boundsState, transition, exitDelay, className, activeClassNameState, ], ); return ( <MotionHighlightContext.Provider value={{ mode, activeValue, setActiveValue: safeSetActiveValue, id, hover, className, transition, disabled, enabled, exitDelay, setBounds: safeSetBounds, clearBounds, activeClassName: activeClassNameState, setActiveClassName: setActiveClassNameState, forceUpdateBounds: (props as ParentModeMotionHighlightProps) ?.forceUpdateBounds, }} > {enabled ? controlledItems ? render(children) : render( React.Children.map(children, (child, index) => ( <MotionHighlightItem key={index} className={props?.itemsClassName} > {child} </MotionHighlightItem> )), ) : children} </MotionHighlightContext.Provider> );}function getNonOverridingDataAttributes( element: React.ReactElement, dataAttributes: Record<string, unknown>,): Record<string, unknown> { return Object.keys(dataAttributes).reduce<Record<string, unknown>>( (acc, key) => { if ((element.props as Record<string, unknown>)[key] === undefined) { acc[key] = dataAttributes[key]; } return acc; }, {}, );}type ExtendedChildProps = React.ComponentProps<'div'> & { id?: string; ref?: React.Ref<HTMLElement>; 'data-active'?: string; 'data-value'?: string; 'data-disabled'?: boolean; 'data-highlight'?: boolean; 'data-slot'?: string;};type MotionHighlightItemProps = React.ComponentProps<'div'> & { children: React.ReactElement; id?: string; value?: string; className?: string; transition?: Transition; activeClassName?: string; disabled?: boolean; exitDelay?: number; asChild?: boolean; forceUpdateBounds?: boolean;};function MotionHighlightItem({ ref, children, id, value, className, transition, disabled = false, activeClassName, exitDelay, asChild = false, forceUpdateBounds, ...props}: MotionHighlightItemProps) { const itemId = React.useId(); const { activeValue, setActiveValue, mode, setBounds, clearBounds, hover, enabled, className: contextClassName, transition: contextTransition, id: contextId, disabled: contextDisabled, exitDelay: contextExitDelay, forceUpdateBounds: contextForceUpdateBounds, setActiveClassName, } = useMotionHighlight(); const element = children as React.ReactElement<ExtendedChildProps>; const childValue = id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId; const isActive = activeValue === childValue; const isDisabled = disabled === undefined ? contextDisabled : disabled; const itemTransition = transition ?? contextTransition; const localRef = React.useRef<HTMLDivElement>(null); React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); React.useEffect(() => { if (mode !== 'parent') return; let rafId: number; let previousBounds: Bounds | null = null; const shouldUpdateBounds = forceUpdateBounds === true || (contextForceUpdateBounds && forceUpdateBounds !== false); const updateBounds = () => { if (!localRef.current) return; const bounds = localRef.current.getBoundingClientRect(); if (shouldUpdateBounds) { if ( previousBounds && previousBounds.top === bounds.top && previousBounds.left === bounds.left && previousBounds.width === bounds.width && previousBounds.height === bounds.height ) { rafId = requestAnimationFrame(updateBounds); return; } previousBounds = bounds; rafId = requestAnimationFrame(updateBounds); } setBounds(bounds); }; if (isActive) { updateBounds(); setActiveClassName(activeClassName ?? ''); } else if (!activeValue) clearBounds(); if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId); }, [ mode, isActive, activeValue, setBounds, clearBounds, activeClassName, setActiveClassName, forceUpdateBounds, contextForceUpdateBounds, ]); if (!React.isValidElement(children)) return children; const dataAttributes = { 'data-active': isActive ? 'true' : 'false', 'aria-selected': isActive, 'data-disabled': isDisabled, 'data-value': childValue, 'data-highlight': true, }; const commonHandlers = hover ? { onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(childValue); element.props.onMouseEnter?.(e); }, onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(null); element.props.onMouseLeave?.(e); }, } : { onClick: (e: React.MouseEvent<HTMLDivElement>) => { setActiveValue(childValue); element.props.onClick?.(e); }, }; if (asChild) { if (mode === 'children') { return React.cloneElement( element, { key: childValue, ref: localRef, className: cn('relative', element.props.className), ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item-container', }), ...commonHandlers, ...props, }, <> <AnimatePresence initial={false}> {isActive && !isDisabled && ( <motion.div layoutId={`transition-background-${contextId}`} data-slot="motion-highlight" className={cn( 'absolute inset-0 bg-muted z-0', contextClassName, activeClassName, )} transition={itemTransition} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { ...itemTransition, delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0), }, }} {...dataAttributes} /> )} </AnimatePresence> <div data-slot="motion-highlight-item" className={cn('relative z-[1]', className)} {...dataAttributes} > {children} </div> </>, ); } return React.cloneElement(element, { ref: localRef, ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item', }), ...commonHandlers, }); } return enabled ? ( <div key={childValue} ref={localRef} data-slot="motion-highlight-item-container" className={cn(mode === 'children' && 'relative', className)} {...dataAttributes} {...props} {...commonHandlers} > {mode === 'children' && ( <AnimatePresence initial={false}> {isActive && !isDisabled && ( <motion.div layoutId={`transition-background-${contextId}`} data-slot="motion-highlight" className={cn( 'absolute inset-0 bg-muted z-0', contextClassName, activeClassName, )} transition={itemTransition} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { ...itemTransition, delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0), }, }} {...dataAttributes} /> )} </AnimatePresence> )} {React.cloneElement(element, { className: cn('relative z-[1]', element.props.className), ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item', }), })} </div> ) : ( children );}export { MotionHighlight, MotionHighlightItem, useMotionHighlight, type MotionHighlightProps, type MotionHighlightItemProps,};
Create interactive card grids with smooth highlight transitions between items.
Use Cases
This free open source React component works well for:
- Tab navigation - Highlight active tabs with smooth transitions built with Next.js
- Menu systems - Show active menu items using TypeScript and Tailwind CSS
- Card interfaces - Interactive grid layouts with visual feedback
- Button groups - Toggle button states with shadcn/ui integration
- Settings panels - Highlight selected options in configuration interfaces
API Reference
MotionHighlight
Prop | Type | Default | Description |
---|---|---|---|
mode | "children" | "parent" | "children" | Highlight rendering strategy |
value | string | null | - | Controlled active value |
defaultValue | string | null | - | Default active value (uncontrolled) |
onValueChange | (value: string | null) => void | - | Callback when value changes |
hover | boolean | false | Activate on hover instead of click |
disabled | boolean | false | Disable all interactions |
enabled | boolean | true | Enable/disable highlighting |
controlledItems | boolean | false | Use MotionHighlightItem manually |
transition | Transition | { type: 'spring', stiffness: 350, damping: 35 } | Motion transition config |
exitDelay | number | 0.2 | Delay before highlight exits |
className | string | - | Highlight element classes |
itemsClassName | string | - | Default item wrapper classes |
containerClassName | string | - | Container classes (parent mode) |
boundsOffset | Partial<Bounds> | - | Positioning offset adjustments |
forceUpdateBounds | boolean | - | Continuously update bounds |
MotionHighlightItem
Prop | Type | Default | Description |
---|---|---|---|
value | string | - | Unique identifier for the item |
id | string | - | Alternative identifier |
asChild | boolean | false | Merge props with child element |
disabled | boolean | - | Disable this specific item |
activeClassName | string | - | Classes when item is active |
transition | Transition | - | Override default transition |
exitDelay | number | - | Override default exit delay |
forceUpdateBounds | boolean | - | Continuously update bounds for this item |
Modes
Children Mode (default):
- Uses Motion's layoutId for smooth transitions
- Highlight is positioned within each item
- Better performance, recommended for most cases
Parent Mode:
- Highlight positioned absolutely in container
- Avoids z-index conflicts
- Better for complex layouts with overlapping elements
Implementation Notes
- Use
data-value
attribute on elements for automatic item identification - Parent mode calculates bounds relative to container position
- boundsOffset useful for border/padding adjustments in parent mode
- Children mode uses layoutId for shared element transitions
- The component handles both mouse and keyboard interactions
- Compatible with shadcn/ui design tokens and styling patterns
Magnetic
Interactive magnetic attraction effects that follow cursor movement. Perfect for React applications requiring engaging hover animations with Next.js integration and TypeScript support.
Pixel Image
Pixelated image reveal component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring grid animations, grayscale transitions, and customizable timing.