Skip to main content
Solid Primitives 2

Keyboard navigable list state management primitives

StageCategoryVersionLast UpdatedDemo
3Inputs1.0.0-next.2 (next)Aug 12, 2026Demo →
Terminal window
npm i @solid-primitives/list-state@next

Primitives for managing keyboard-navigable list state. Provides accessible, fully-featured list state management with support for single-select and multi-select patterns.

Features

  • Full keyboard support: Arrow keys, Vim bindings (hjkl), Home/End, Tab
  • Configurable orientation: Vertical or horizontal lists
  • Bidirectional text: RTL and LTR support
  • List looping: Optional looping at list boundaries
  • Range selection: Shift+Arrow for multi-select range expansion
  • Type-safe: Full TypeScript support with generic item types
  • SSR-safe: Works in both browser and server environments
  • Zero dependencies: Relies only on Solid.js primitives

createListState

A reactive primitive for single-select list navigation. Returns an active signal for the currently selected item and an onKeyDown handler for keyboard events.

import { createListState } from "@solid-primitives/list-state";
import { createSignal } from "solid-js";
export function MyList() {
const items = ["Apple", "Banana", "Cherry"];
const { active, setActive, onKeyDown } = createListState({
items,
initialActive: items[0],
});
return (
<ul role="listbox" onKeyDown={onKeyDown}>
<For each={items}>
{(item) => (
<li
role="option"
aria-selected={active() === item}
onClick={() => setActive(item)}
class={{ selected: active() === item }}
>
{item}
</li>
)}
</For>
</ul>
);
}

Props

interface ListStateOptions<T> {
/** The items in the list. Should be in the same order as they appear in the DOM. */
items: MaybeAccessor<T[]>;
/** The initially active item. @default undefined */
initialActive?: T | undefined;
/** The orientation of the list. @default "vertical" */
orientation?: MaybeAccessor<"vertical" | "horizontal">;
/** Whether the list should loop. @default true */
loop?: MaybeAccessor<boolean>;
/** The text direction of the list. @default "ltr" */
textDirection?: MaybeAccessor<"ltr" | "rtl">;
/** Whether tab key presses should be handled. @default true */
handleTab?: MaybeAccessor<boolean>;
/** Whether vim movement key bindings should be used. @default false */
vimMode?: MaybeAccessor<boolean>;
/** The vim movement key bindings. @default { up: 'k', down: 'j', right: 'l', left: 'h' } */
vimKeys?: MaybeAccessor<{ up: string; down: string; right: string; left: string }>;
/** Callback fired when the active item changes. */
onActiveChange?: (active: T | undefined) => void;
}

Returns

interface ListStateReturn<T> {
active: Accessor<T | undefined>;
setActive: (value: T | undefined) => void;
onKeyDown: (event: KeyboardEvent) => void;
}

Keyboard Shortcuts

KeyAction
/ Navigate vertically (or ← / → for horizontal)
HomeJump to first item
EndJump to last item
TabNavigate forward (if handleTab is true)
Shift+TabNavigate backward (if handleTab is true)
k / jVim navigation (if vimMode is true)

createMultiSelectListState

A reactive primitive for multi-select list navigation with range selection. Maintains three separate states: cursor (focused item), active (range of focused items), and selected (user-selected items).

import { createMultiSelectListState } from "@solid-primitives/list-state";
export function MyMultiSelectList() {
const items = ["Apple", "Banana", "Cherry"];
const { cursor, active, selected, setCursorActive, toggleSelected, onKeyDown } =
createMultiSelectListState({
items,
});
return (
<ul role="listbox" onKeyDown={onKeyDown}>
<For each={items}>
{(item) => (
<li
role="option"
aria-selected={selected().includes(item)}
onClick={() => setCursorActive(item)}
onDoubleClick={() => toggleSelected(item)}
class={{
cursor: cursor() === item,
selected: selected().includes(item),
}}
>
{item}
</li>
)}
</For>
</ul>
);
}

Props

interface MultiSelectListStateOptions<T> {
// Same as ListStateOptions<T>, plus:
/** The initially focused item (cursor). @default undefined */
initialCursor?: T | undefined;
/** The initially active items (range). @default [] */
initialActive?: T[];
/** The initially selected items. @default [] */
initialSelected?: T[];
/** Callback fired when the cursor changes. */
onCursorChange?: (cursor: T | undefined) => void;
/** Callback fired when the active items change. */
onActiveChange?: (active: T[]) => void;
/** Callback fired when the selected items change. */
onSelectedChange?: (selected: T[]) => void;
}

Returns

interface MultiSelectListStateReturn<T> {
cursor: Accessor<T | undefined>;
setCursor: (value: T | undefined) => void;
active: Accessor<T[]>;
setActive: (value: T[]) => void;
setCursorActive: (item: T | undefined) => void;
selected: Accessor<T[]>;
setSelected: (value: T[]) => void;
toggleSelected: (item: T) => void;
onKeyDown: (event: KeyboardEvent) => void;
}

Keyboard Shortcuts

All shortcuts from createListState plus:

KeyAction
Shift+ / Expand/contract selection range

Types

export type Orientation = "vertical" | "horizontal";
export type TextDirection = "ltr" | "rtl";
export type VimKeys = {
up: string;
down: string;
right: string;
left: string;
};

Server-Side Rendering

Both primitives are fully SSR-safe and will work correctly in both browser and server environments.

Credits

This primitive was adapted from corvu's solid-list by Jasmin Noetzli and migrated to Solid Primitives for Solid 2.0. Used under the MIT License.

Changelog

See CHANGELOG.md

Solid Primitives 2High-quality reactive primitives for building applications in Solid2
Community
githubdiscord