Skip to content
txbrendan/ui

Glide Highlight

beta

One 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. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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