Highlight

An animated highlight component that tracks elements with smooth spring animations.

Open in
React
Vue
Svelte
Next.js
demo-primitives-effects-highlight.tsx
"use client";

import {
  Highlight,
  HighlightItem,
} from "@/components/unlumen-ui/primitives/effects/highlight";

const ITEMS = [
  { id: "react", label: "React" },
  { id: "vue", label: "Vue" },
  { id: "svelte", label: "Svelte" },
  { id: "next", label: "Next.js" },
];

export const HighlightDemo = ({ hover = true }: { hover?: boolean }) => {
  return (
    <div className="flex items-center justify-center min-h-[300px] w-full p-8">
      <Highlight
        mode="parent"
        hover={hover}
        defaultValue={hover ? undefined : "react"}
        className="rounded-lg bg-muted"
        containerClassName="flex gap-3 p-4 flex-wrap justify-center"
      >
        {ITEMS.map((item) => (
          <HighlightItem
            key={item.id}
            value={item.id}
            className="px-4 py-2 rounded-md font-medium cursor-pointer"
          >
            <div>{item.label}</div>
          </HighlightItem>
        ))}
      </Highlight>
    </div>
  );
};

Installation

Install the following dependencies:

Copy and paste the following code into your project:

components/unlumen-ui/primitives/effects/highlight.tsx
"use client";

import * as React from "react";
import { AnimatePresence, Transition, motion } from "motion/react";

import { cn } from "@/lib/utils";

type HighlightMode = "children" | "parent";

type Bounds = {
  top: number;
  left: number;
  width: number;
  height: number;
};

type HighlightContextType<T extends string> = {
  mode: HighlightMode;
  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 HighlightContext = React.createContext<
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  HighlightContextType<any> | undefined
>(undefined);

function useHighlight<T extends string>(): HighlightContextType<T> {
  const context = React.useContext(HighlightContext);
  if (!context) {
    throw new Error("useHighlight must be used within a HighlightProvider");
  }
  return context as unknown as HighlightContextType<T>;
}

type BaseHighlightProps<T extends string> = {
  mode?: HighlightMode;
  value?: T | null;
  defaultValue?: T | null;
  onValueChange?: (value: T | null) => void;
  className?: string;
  transition?: Transition;
  hover?: boolean;
  disabled?: boolean;
  enabled?: boolean;
  exitDelay?: number;
};

type ParentModeHighlightProps = {
  boundsOffset?: Partial<Bounds>;
  containerClassName?: string;
  forceUpdateBounds?: boolean;
};

type ControlledParentModeHighlightProps<T extends string> =
  BaseHighlightProps<T> &
    ParentModeHighlightProps & {
      mode: "parent";
      controlledItems: true;
      children: React.ReactNode;
    };

type ControlledChildrenModeHighlightProps<T extends string> =
  BaseHighlightProps<T> & {
    mode?: "children" | undefined;
    controlledItems: true;
    children: React.ReactNode;
  };

type UncontrolledParentModeHighlightProps<T extends string> =
  BaseHighlightProps<T> &
    ParentModeHighlightProps & {
      mode: "parent";
      controlledItems?: false;
      itemsClassName?: string;
      children: React.ReactElement | React.ReactElement[];
    };

type UncontrolledChildrenModeHighlightProps<T extends string> =
  BaseHighlightProps<T> & {
    mode?: "children";
    controlledItems?: false;
    itemsClassName?: string;
    children: React.ReactElement | React.ReactElement[];
  };

type HighlightProps<T extends string> = React.ComponentProps<"div"> &
  (
    | ControlledParentModeHighlightProps<T>
    | ControlledChildrenModeHighlightProps<T>
    | UncontrolledParentModeHighlightProps<T>
    | UncontrolledChildrenModeHighlightProps<T>
  );

function Highlight<T extends string>({ ref, ...props }: HighlightProps<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 {
    boundsOffset = { top: 0, left: 0, width: 0, height: 0 },
    containerClassName,
    forceUpdateBounds,
  } = props as ParentModeHighlightProps;
  const { itemsClassName } = props as {
    itemsClassName?: string;
  };

  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>("");

  function safeSetActiveValue(id: T | null) {
    setActiveValue((prev) => {
      if (prev !== id) onValueChange?.(id as T);
      return prev === id ? prev : id;
    });
  }

  function safeSetBounds(bounds: DOMRect) {
    if (!localRef.current) return;

    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;
    });
  }

  function clearBounds() {
    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);
  });

  function render(children: React.ReactNode) {
    if (mode === "parent") {
      return (
        <div
          ref={localRef}
          data-slot="motion-highlight-container"
          className={cn("relative", 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;
  }

  return (
    <HighlightContext.Provider
      value={{
        mode,
        activeValue,
        setActiveValue: safeSetActiveValue,
        id,
        hover,
        className,
        transition,
        disabled,
        enabled,
        exitDelay,
        setBounds: safeSetBounds,
        clearBounds,
        activeClassName: activeClassNameState,
        setActiveClassName: setActiveClassNameState,
        forceUpdateBounds,
      }}
    >
      {enabled
        ? controlledItems
          ? render(children)
          : render(
              React.Children.map(children, (child, index) => (
                <HighlightItem key={index} className={itemsClassName}>
                  {child}
                </HighlightItem>
              )),
            )
        : children}
    </HighlightContext.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;
    },
    {},
  );
}

function assignRef<T>(ref: React.Ref<T> | undefined, node: T | null) {
  if (!ref) return;
  if (typeof ref === "function") {
    ref(node);
    return;
  }
  (ref as React.RefObject<T | null>).current = node;
}

function useComposedRefs<T>(
  childRef: React.Ref<T> | undefined,
  localRef: React.Ref<T> | undefined,
  forwardedRef: React.Ref<T> | undefined,
): React.RefCallback<T> {
  return React.useCallback(
    (node) => {
      assignRef(childRef, node);
      assignRef(localRef, node);
      assignRef(forwardedRef, node);
    },
    [childRef, localRef, forwardedRef],
  );
}

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 HighlightItemProps = 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 HighlightItem({
  ref,
  children,
  id,
  value,
  className,
  transition,
  disabled = false,
  activeClassName,
  exitDelay,
  asChild = false,
  forceUpdateBounds,
  ...props
}: HighlightItemProps) {
  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,
  } = useHighlight();

  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);
  const childRef = (element.props as ExtendedChildProps).ref;
  const composedRef = useComposedRefs<HTMLElement>(
    childRef,
    localRef as React.RefObject<HTMLElement | null>,
    ref as React.Ref<HTMLElement>,
  );

  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) {
    const { ref: _childRef, ...childProps } =
      element.props as ExtendedChildProps;

    if (mode === "children") {
      return React.createElement(
        element.type,
        {
          ...childProps,
          key: childValue,
          ref: composedRef,
          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.createElement(element.type, {
      ...childProps,
      ref: composedRef,
      ...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 {
  Highlight,
  HighlightItem,
  useHighlight,
  type HighlightProps,
  type HighlightItemProps,
};

Update the import paths to match your project setup.

Usage

import {
  Highlight,
  HighlightItem,
} from "@/components/unlumen-ui/primitives/effects/highlight";
<Highlight
  mode="parent"
  className="rounded-lg bg-muted"
  containerClassName="flex gap-2 p-2"
>
  <HighlightItem value="a">
    <div className="px-4 py-2 cursor-pointer">Option A</div>
  </HighlightItem>
  <HighlightItem value="b">
    <div className="px-4 py-2 cursor-pointer">Option B</div>
  </HighlightItem>
</Highlight>

API Reference

Highlight

mode?
"children" | "parent"
"children"

Highlight mode — parent renders one shared background, children renders per-item.

hover?
boolean
false

Trigger highlight on hover instead of click.

value?
string | null

Controlled active value.

defaultValue?
string | null

Initial active value (uncontrolled).

onValueChange?
(value: string | null) => void

Callback when the active value changes.

className?
string

CSS classes applied to the highlight background element.

containerClassName?
string

CSS classes for the container div (parent mode only).

transition?
Transition
{ type: 'spring', stiffness: 350, damping: 35 }

Motion transition config.

exitDelay?
number
0.2

Delay in seconds before the exit animation starts.

enabled?
boolean
true

Enable or disable the highlight effect.

disabled?
boolean
false

Disable all items.

controlledItems?
boolean
false

Use manually wrapped HighlightItem children instead of auto-wrapping.

HighlightItem

value?
string

Unique identifier for this item.

disabled?
boolean
false

Disable highlight for this specific item.

activeClassName?
string

CSS classes added to the highlight when this item is active.

className?
string

CSS classes for the item wrapper.

transition?
Transition

Override the transition for this item.

exitDelay?
number

Override exit delay for this item.

asChild?
boolean
false

Merge props and behavior onto the child element.

forceUpdateBounds?
boolean

Continuously update bounds via rAF (useful for animated layouts).

Built by Léo from Unlumen :3

Last updated: 4/29/2026