Glide Highlight
betaOne highlight for a whole list, which glides from row to row on a spring instead of each row lighting on its own.
Preview
Sweep the pointer down the list. Arrow keys jump the highlight.
Open: Inbox
Move the pointer along the row.
Showing: Overview
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/glide-highlight
Use it for
- A list, menu, or row of links where the pointer sweeps across many rows. One highlight that glides reads as one motion, where a highlight per row blinks.
- Call useGlide with the list's ref and the highlight's ref. Call follow(row, event) from pointerover and pointermove, to(row) from focus or highlight events, and hide() when they end.
- follow leans the highlight toward the pointer during a quick sweep, so it crosses rows at an even speed. It settles back onto its row once the pointer rests.
- A list that already marks one row, like data-highlighted, needs no hook. Pass that attribute as track, and the highlight follows the marked row by itself.
- Give a row data-tone, like danger, and the highlight takes that tone when it arrives there.
- Pass instant for moves the keys make, so a held arrow key always shows one row fully lit.
- Style each row's text with data-[lit]:. A row gets data-lit once the highlight is more than halfway onto it, so text and highlight arrive together.
Avoid it for
- A single control's hover. One button needs no glide. Its own hover fill is enough.
- Showing the selected row. The highlight follows the pointer and the keys. Mark the chosen row with its own fill or aria-current.
- A list that is not positioned and isolated. The highlight sits behind the rows, so the list needs both, or it measures wrong and hides behind the page.
Props
- useGlide
Helper. Moves the highlight. axis is "y" for a column and "x" for a row of links. span "row" fits each row, and "full" spans the list's width so it only moves up and down.
(listRef: RefObject<HTMLElement | null>, highlightRef: RefObject<HTMLSpanElement | null>, options?: { axis?: "x" | "y"; span?: "row" | "full" }) => Glide
- Glide
Type. follow sends it to the row under the pointer and leans it ahead on a quick sweep. to sends it to a row: it fades in there the first time and glides after that. hide fades it out where it is. press darkens it. row is the row it is on.
{ follow: (row: HTMLElement, pointer: { clientX: number; clientY: number }) => void; to: (row: HTMLElement, move?: { lead?: number; instant?: boolean }) => void; hide: (options?: { instant?: boolean }) => void; press: (pressed: boolean) => void; row: () => HTMLElement | null }
- GlideHighlight
Part. The highlight. Put it first inside the list, with the same ref, and give it its fill as a class, like bg-foreground/8.
ComponentProps<"span">
- span
On GlideHighlight. Match the span you passed useGlide.
"row" | "full" · default "row"
- track
On GlideHighlight. An attribute that marks one row in its parent at a time, like data-highlighted. The highlight then follows that row with no hook: it glides when the pointer moved the mark and jumps when the keys did. It sets data-glide on the parent, so rows can drop their own fill there.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| useGlide | (listRef: RefObject<HTMLElement | null>, highlightRef: RefObject<HTMLSpanElement | null>, options?: { axis?: "x" | "y"; span?: "row" | "full" }) => Glide | - | Helper. Moves the highlight. axis is "y" for a column and "x" for a row of links. span "row" fits each row, and "full" spans the list's width so it only moves up and down. |
| Glide | { follow: (row: HTMLElement, pointer: { clientX: number; clientY: number }) => void; to: (row: HTMLElement, move?: { lead?: number; instant?: boolean }) => void; hide: (options?: { instant?: boolean }) => void; press: (pressed: boolean) => void; row: () => HTMLElement | null } | - | Type. follow sends it to the row under the pointer and leans it ahead on a quick sweep. to sends it to a row: it fades in there the first time and glides after that. hide fades it out where it is. press darkens it. row is the row it is on. |
| GlideHighlight | ComponentProps<"span"> | - | Part. The highlight. Put it first inside the list, with the same ref, and give it its fill as a class, like bg-foreground/8. |
| span | "row" | "full" | "row" | On GlideHighlight. Match the span you passed useGlide. |
| track | string | - | On GlideHighlight. An attribute that marks one row in its parent at a time, like data-highlighted. The highlight then follows that row with no hook: it glides when the pointer moved the mark and jumps when the keys did. It sets data-glide on the parent, so rows can drop their own fill there. |
Accessibility
- The highlight is hidden from screen readers. It only shows where the pointer or the keys are.
- It does not take focus or show focus. Keep each row's own focus ring, or draw the ring on the highlight while it sits on the focused row.
- Reduced motion, or motion off, makes it jump from row to row instead of gliding.
- Forced colors paint its fill away. Give the lit row an outline there, so the highlight still shows.
Examples
Glide one highlight down a list as the pointer moves
import { useRef } from "react";
function GlideList() {
const list = useRef<HTMLUListElement>(null);
const highlight = useRef<HTMLSpanElement>(null);
const glide = useGlide(list, highlight);
return (
<ul
ref={list}
className="relative isolate flex flex-col"
onPointerMove={(event) => {
const row = (event.target as Element).closest("button");
if (row) glide.follow(row, event);
}}
onPointerLeave={() => glide.hide()}
>
<GlideHighlight ref={highlight} className="rounded-md bg-foreground/8" />
{["Inbox", "Drafts", "Sent"].map((label) => (
<li key={label}>
<button type="button" className="w-full px-3 py-2 text-left text-muted-foreground data-[lit]:text-foreground">
{label}
</button>
</li>
))}
</ul>
);
}
<GlideList />Follow the row a list already marks, with no hook
<ul role="listbox" className="relative isolate">
<GlideHighlight track="data-highlighted" className="rounded-md bg-accent" />
{/* Rows that set data-highlighted on the active one */}
</ul>Jump with no glide when the arrow keys move the highlight
glide.to(row, { instant: true });