mm.
← All components

HighlightList

One rounded bar that slides between rows instead of many that blink on and off.

2025 — nowMotion Lead / Northwind2023 — 2025Interface Engineer / Halcyon Labs
2021 — 2023Product Designer / Fieldnote
2019 — 2021Design Intern / Paper Kite

The idea

A per-row hover background flickers as the cursor crosses the gap between rows, and it says nothing about where you came from. A single shared element re-targeting its own position reads as one physical object — and carries the relationship between the row you left and the row you arrived at.

Install

terminal1 line
npm i @mihirmodi/ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

HighlightList — usage22 lines
import { HighlightList } from "@mihirmodi/ui";

// Two things people get wrong, both forced by the bar being measured
// from each item's box:
//   1. The ITEMS carry the padding, not the list.
//   2. The list gets a negative horizontal margin (-mx-3 against px-3),
//      so the text stays aligned to your column while the bar bleeds
//      slightly past it — behind the text, rather than boxing it in.
<HighlightList
  className="-mx-3 flex flex-col text-[14px]"
  itemClassName="group flex gap-10 px-3 py-2.5"
  items={roles.map((role) => ({
    key: role.id,
    to: role.href,
    children: (
      <>
        <span className="w-[140px] shrink-0 text-muted">{role.dates}</span>
        <span className="text-gray-1000">{role.title}</span>
      </>
    ),
  }))}
/>

Props

PropNotes
itemsHighlightItem[]Each needs a `key` and `children`; `to` routes, `href` does not.
classNamestringOn the list. `flex flex-col` for rows, `flex` for a horizontal strip — the bar tracks all four box values, so both work.
itemClassNamestringOn every item. The items must carry the padding: the bar is measured from their boxes.
radiusnumberRadius of the bar. Match your item's own rounding.
barClassNamestringThe bar's paint. On a grey or glass surface the default step disappears — use `bg-highlight`.
linkLinkComponentOverride the routed link component for this list only.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/HighlightList.tsx164 lines
"use client";

import { useState, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { useLinkComponent, type LinkComponent } from "../lib/link";

type Rect = { x: number; y: number; w: number; h: number };

export type HighlightItem = {
  key: string;
  /** An in-app destination — routed through your link component, so it does not reload. */
  to?: string;
  /** Anything off-site or outside the router: files, mailto, another origin. */
  href?: string;
  external?: boolean;
  /** Renders a `<button>` instead of a link — for a list of actions rather than destinations. */
  onClick?: () => void;
  /** Marks this row as the chosen one. Style it yourself through `children`. */
  active?: boolean;
  /** Accessible name, for a row whose content is an icon rather than words. */
  label?: string;
  children: ReactNode;
};

export type HighlightListProps = {
  items: HighlightItem[];
  /** On the list. Give it `flex flex-col` for rows, `flex` for a horizontal strip. */
  className?: string;
  /** On every item. The items carry the padding — see below. */
  itemClassName?: string;
  /** Radius of the bar. Match it to the item's own rounding. */
  radius?: number;
  /**
   * The bar's paint. Default `bg-gray-100`, which reads on the page background.
   * On a grey or glass surface that step disappears — use `bg-highlight`, the
   * elevated surface the dock's own highlight is drawn on.
   */
  barClassName?: string;
  /** Override the routed link component for this list only. */
  link?: LinkComponent;
};

/**
 * A list where a single rounded bar slides to whichever item you are pointing at.
 *
 * The whole idea is that there is **one** bar. A per-item background would flicker
 * on and off as the cursor crosses a gap, and says nothing about where you came
 * from. One shared element re-targeting its own position reads as a physical
 * slide, and carries the relationship between the item you left and the one you
 * arrived at.
 *
 * Two things follow from that, and they are the two things people get wrong:
 *
 * 1. **The items carry the padding**, not the list. The bar is measured from each
 *    item's box, so padding on the item is what makes the bar the same size
 *    everywhere and what gives it room to sit behind the text.
 * 2. **Give the list a negative horizontal margin** (`-mx-3` against `px-3` items).
 *    That keeps the text aligned to your column while the bar bleeds slightly past
 *    it — so the bar looks like it is behind the text rather than boxing it in.
 *
 * It works horizontally too: the bar tracks x, y, width and height, so a flex-row
 * list gets a bar that slides sideways and resizes between items of different
 * widths. `Dock` uses the same mechanism.
 *
 * Focus moves the bar as well as hover, so the keyboard gets this for free.
 */
export function HighlightList({
  items,
  className = "",
  itemClassName = "",
  radius = 10,
  barClassName = "bg-gray-100",
  link,
}: HighlightListProps) {
  const [rect, setRect] = useState<Rect | null>(null);
  const [active, setActive] = useState(false);
  const reduce = useReducedMotion();
  const Link = useLinkComponent(link);

  const move = (el: HTMLElement) => {
    setRect({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
    setActive(true);
  };

  const handlers = {
    onMouseEnter: (e: { currentTarget: HTMLElement }) => move(e.currentTarget),
    onFocus: (e: { currentTarget: HTMLElement }) => move(e.currentTarget),
    onBlur: () => setActive(false),
  };

  return (
    <LazyMotion features={domAnimation} strict>
      <div className={`relative isolate ${className}`} onMouseLeave={() => setActive(false)}>
        <m.div
          aria-hidden
          className={`pointer-events-none absolute left-0 top-0 -z-10 ${barClassName}`}
          style={{ borderRadius: radius }}
          // `initial={false}` matters: without it the bar animates in from nothing
          // on mount, in a corner, before anyone has pointed at anything.
          initial={false}
          animate={
            rect
              ? { x: rect.x, y: rect.y, width: rect.w, height: rect.h, opacity: active ? 1 : 0 }
              : { opacity: 0 }
          }
          transition={
            reduce
              ? { duration: 0.15 }
              : {
                  type: "spring",
                  bounce: 0.2,
                  duration: 0.4,
                  // Position springs; opacity does not. A spring on opacity would
                  // let the bar overshoot past 1 and clip, which just looks broken.
                  opacity: { duration: 0.16, ease: "easeOut" },
                }
          }
        />
        {items.map((it) =>
          it.onClick ? (
            <button
              key={it.key}
              type="button"
              onClick={it.onClick}
              aria-label={it.label}
              aria-pressed={it.active}
              className={itemClassName}
              {...handlers}
            >
              {it.children}
            </button>
          ) : it.to ? (
            <Link
              key={it.key}
              href={it.to}
              aria-label={it.label}
              // A routed row marked active is the page you are on.
              aria-current={it.active ? "page" : undefined}
              className={itemClassName}
              {...handlers}
            >
              {it.children}
            </Link>
          ) : it.href ? (
            <a
              key={it.key}
              href={it.href}
              target={it.external ? "_blank" : undefined}
              rel={it.external ? "noopener" : undefined}
              className={itemClassName}
              {...handlers}
            >
              {it.children}
            </a>
          ) : (
            <div key={it.key} className={itemClassName} onMouseEnter={(e) => move(e.currentTarget)}>
              {it.children}
            </div>
          )
        )}
      </div>
    </LazyMotion>
  );
}