A keyboard-first list with j/k roving navigation, a sliding active-row indicator, and Enter to activate.
npx shadcn@latest add @paragon/keyboard-list"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>
);
}