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