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 and Enter to
 * activate. A single active-row indicator slides between rows on a transform,
 * so the highlight glides rather than repaints per row — the right call for a
 * 100x/day keyboard surface where per-row enter animation would be noise. Uses
 * roving tabindex (one tab stop) with aria-activedescendant, so focus stays on
 * the list while the active row is announced. The slide is skipped under
 * reduced motion; navigation and activation are 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 [active, setActive] = React.useState(0);
  const [indicator, setIndicator] = React.useState({ y: 0, h: 0, ready: false });
  const baseId = React.useId();

  const measure = React.useCallback((index: number) => {
    const row = rowRefs.current[index];
    const list = listRef.current;
    if (!row || !list) return;
    setIndicator({
      y: row.offsetTop,
      h: row.offsetHeight,
      ready: true,
    });
  }, []);

  React.useLayoutEffect(() => {
    measure(active);
  }, [active, items, measure]);

  const move = (delta: number) => {
    setActive((current) =>
      Math.min(items.length - 1, Math.max(0, current + delta)),
    );
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLUListElement>) => {
    switch (e.key) {
      case "j":
      case "ArrowDown":
        e.preventDefault();
        move(1);
        break;
      case "k":
      case "ArrowUp":
        e.preventDefault();
        move(-1);
        break;
      case "Home":
        e.preventDefault();
        setActive(0);
        break;
      case "End":
        e.preventDefault();
        setActive(items.length - 1);
        break;
      case "Enter":
        e.preventDefault();
        onActivate?.(items[active], active);
        break;
    }
  };

  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 */}
      <div
        aria-hidden
        className={cn(
          "pointer-events-none absolute inset-x-1.5 z-0 rounded-lg bg-accent",
          !isStatic && "transition-transform duration-200 ease-out",
        )}
        style={{
          height: indicator.h,
          transform: `translateY(${indicator.y}px)`,
          opacity: indicator.ready ? 1 : 0,
        }}
      />

      {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",
                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>
  );
}