Keyboard List
Navigation

Keyboard List

A keyboard-first list with j/k roving navigation, a sliding active-row indicator, and Enter to activate.

Install

npx shadcn@latest add @paragon/keyboard-list

keyboard-list.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface KeyboardListItem {
  id: string;
  title: string;
  meta?: string;
}

export interface KeyboardListProps
  extends Omit<React.ComponentProps<"ul">, "onSelect"> {
  items: KeyboardListItem[];
  /** Fires on Enter or click. */
  onActivate?: (item: KeyboardListItem, index: number) => void;
  /** Renders the active indicator without the slide. */
  static?: boolean;
}

/**
 * A keyboard-first list with j/k (and Arrow) roving navigation, typeahead,
 * and Enter to activate. A single active-row indicator slides between rows on
 * a 150ms transform — measured off the row, so it retargets mid-flight and
 * glides rather than repainting per row. On first paint it fades in place,
 * never sliding in from zero. Uses roving focus on the list itself with
 * aria-activedescendant, so focus stays put while the active row is
 * announced; keyboard moves keep the row in view with block:"nearest". The
 * slide is skipped under reduced motion; navigation is unaffected.
 */
export function KeyboardList({
  items,
  onActivate,
  static: isStatic = false,
  className,
  ...props
}: KeyboardListProps) {
  const listRef = React.useRef<HTMLUListElement>(null);
  const rowRefs = React.useRef<Array<HTMLLIElement | null>>([]);
  const indicatorRef = React.useRef<HTMLDivElement>(null);
  const visibleRef = React.useRef(false);
  const [active, setActive] = React.useState(0);
  const activeRef = React.useRef(active);
  activeRef.current = active;
  const baseId = React.useId();
  const typeahead = React.useRef<{
    text: string;
    timer: ReturnType<typeof setTimeout> | null;
  }>({ text: "", timer: null });

  const position = React.useCallback((index: number, animate: boolean) => {
    const indicator = indicatorRef.current;
    if (!indicator) return;
    const row = rowRefs.current[index];
    if (!row) {
      indicator.style.opacity = "0";
      visibleRef.current = false;
      return;
    }
    const appearing = !visibleRef.current;
    if (!animate || appearing) indicator.style.transitionProperty = "none";
    indicator.style.height = `${row.offsetHeight}px`;
    indicator.style.transform = `translateY(${row.offsetTop}px)`;
    if (!animate) {
      indicator.style.opacity = "1";
      void indicator.offsetWidth;
      indicator.style.transitionProperty = "";
    } else if (appearing) {
      // Land in place and fade — never slide in from a stale position.
      void indicator.offsetWidth;
      indicator.style.transitionProperty = "";
      indicator.style.opacity = "1";
    } else {
      indicator.style.opacity = "1";
    }
    visibleRef.current = true;
  }, []);

  React.useLayoutEffect(() => {
    position(active, !isStatic);
  }, [position, active, items, isStatic]);

  // Layout shifts snap the indicator; skip the observer's initial fire so it
  // cannot cancel the first-appearance fade.
  React.useEffect(() => {
    const list = listRef.current;
    if (!list) return;
    let initial = true;
    const observer = new ResizeObserver(() => {
      if (initial) {
        initial = false;
        return;
      }
      position(activeRef.current, false);
    });
    observer.observe(list);
    return () => observer.disconnect();
  }, [position]);

  React.useEffect(() => {
    const state = typeahead.current;
    return () => {
      if (state.timer) clearTimeout(state.timer);
    };
  }, []);

  const focusRow = (index: number) => {
    const clamped = Math.min(items.length - 1, Math.max(0, index));
    setActive(clamped);
    rowRefs.current[clamped]?.scrollIntoView({ block: "nearest" });
  };

  const handleTypeahead = (key: string) => {
    const state = typeahead.current;
    if (state.timer) clearTimeout(state.timer);
    state.text += key.toLowerCase();
    state.timer = setTimeout(() => {
      state.text = "";
    }, 600);
    // A fresh single character searches from the next row (cycling through
    // same-letter matches); a growing buffer re-tests the current row.
    const from = state.text.length === 1 ? active + 1 : active;
    for (let step = 0; step < items.length; step++) {
      const index = (from + step) % items.length;
      if (items[index].title.toLowerCase().startsWith(state.text)) {
        focusRow(index);
        return;
      }
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLUListElement>) => {
    switch (e.key) {
      case "j":
      case "ArrowDown":
        e.preventDefault();
        focusRow(active + 1);
        break;
      case "k":
      case "ArrowUp":
        e.preventDefault();
        focusRow(active - 1);
        break;
      case "Home":
        e.preventDefault();
        focusRow(0);
        break;
      case "End":
        e.preventDefault();
        focusRow(items.length - 1);
        break;
      case "Enter":
        e.preventDefault();
        onActivate?.(items[active], active);
        break;
      default:
        if (
          e.key.length === 1 &&
          !e.metaKey &&
          !e.ctrlKey &&
          !e.altKey &&
          /\S/.test(e.key) &&
          e.key !== "j" &&
          e.key !== "k"
        ) {
          handleTypeahead(e.key);
        }
    }
  };

  return (
    <ul
      ref={listRef}
      role="listbox"
      aria-label="Keyboard-navigable list"
      aria-activedescendant={`${baseId}-${active}`}
      tabIndex={0}
      onKeyDown={handleKeyDown}
      data-slot="keyboard-list"
      className={cn(
        "relative w-full max-w-sm overflow-hidden rounded-xl bg-card p-1.5 shadow-border outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        className,
      )}
      {...props}
    >
      {/* Sliding active indicator — 150ms keeps a 100x/day surface snappy. */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-x-1.5 z-0 rounded-lg bg-accent opacity-0 [transition-property:transform,height,opacity] duration-150 ease-out motion-reduce:transition-none"
        ref={indicatorRef}
      />

      {items.map((item, i) => {
        const isActive = i === active;
        return (
          <li
            key={item.id}
            id={`${baseId}-${i}`}
            role="option"
            aria-selected={isActive}
            ref={(el) => {
              rowRefs.current[i] = el;
            }}
            onClick={() => {
              setActive(i);
              onActivate?.(item, i);
            }}
            onMouseEnter={() => setActive(i)}
            className="relative z-10 flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5"
          >
            <span
              aria-hidden
              className={cn(
                "size-1.5 shrink-0 rounded-full transition-colors duration-150 ease-out",
                isActive ? "bg-primary" : "bg-transparent",
              )}
            />
            <span className="min-w-0 flex-1 truncate text-sm font-medium">
              {item.title}
            </span>
            {item.meta && (
              <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
                {item.meta}
              </span>
            )}
          </li>
        );
      })}
    </ul>
  );
}