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
npm i @mihirmodi/ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| itemsHighlightItem[] | HighlightItem[] | Each needs a `key` and `children`; `to` routes, `href` does not. |
| classNamestring | string | On the list. `flex flex-col` for rows, `flex` for a horizontal strip — the bar tracks all four box values, so both work. |
| itemClassNamestring | string | On every item. The items must carry the padding: the bar is measured from their boxes. |
| radiusnumber | number= 10 | Radius of the bar. Match your item's own rounding. |
| barClassNamestring | string= "bg-gray-100" | The bar's paint. On a grey or glass surface the default step disappears — use `bg-highlight`. |
| linkLinkComponent | LinkComponent | Override 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.
"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>
);
}