npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@navix/react

v0.1.3

Published

Spatial navigation library for web-based TV platforms (Tizen, WebOS, browser)

Readme

Navix

A spatial navigation library for web-based TV platforms (Tizen, WebOS, browser).

Navix manages keyboard-driven focus across a tree of components — the same model used in every TV UI. You describe the structure (which rows, which grids, which buttons), and Navix routes arrow key events through the tree automatically.


Architecture

Navigation logic is self-contained. The focus tree, behaviors, and input manager all live inside @navix/react — no separate core package needed.

Event flow: keydown/keyupInputManagerFocusTreeFocusNode tree (root down to the active leaf).


Exports

@navix/react

React 18+ adapter. Peer dependency on react and react-dom.

| Export | Description | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | NavixScope | Creates the FocusTree, attaches keydown/keyup listeners to document, provides root node via context. | | useFocusable(key, callbacks?, createBehavior?) | Hook. Creates a FocusNode, registers it with the nearest parent, returns focused, directlyFocused, focusSelf, FocusProvider. Accepts lifecycle callbacks and an optional behavior factory. | | NavixHorizontalList | Node + ListBehavior('horizontal'). Accepts className, focusedClassName, style, focusedStyle — renders a wrapper div only when any of these are provided. | | NavixVerticalList | Node + ListBehavior('vertical'). Accepts className, focusedClassName, style, focusedStyle — renders a wrapper div only when any of these are provided. | | NavixGrid | Node + GridBehavior(columns). Syncs columns prop on every render. Accepts className, focusedClassName, style, focusedStyle. | | NavixButton | Leaf node. Handles enter events. Supports onClick, onLongPress, onDoublePress, style, focusedStyle, className, focusedClassName, and render prop children ({ focused }) => ReactNode. | | NavixSwitch | Controlled boolean toggle built on NavixButton. Render prop (checked, focused) => ReactNode. Exposes checked and onChange. No click/press callbacks. | | NavixInput | Leaf node + InputBehavior. Two-state: idle (navigable) and editing (focus trapped, nav events swallowed). Enter starts editing, Enter/back stops editing. Supports style, focusedStyle, editingStyle, className, focusedClassName, editingClassName. Render prop ({ value, focused, editing, inputRef, stopEditing }) => ReactNode — omit for a default <input>. | | NavixExpandable | Node + ExpandableBehavior. Render prop exposes isExpanded, focused, directlyFocused, expand, collapse. | | NavixDropdown | Node + ExpandableBehavior. Render prop exposes isExpanded, focused, directlyFocused, collapse. Supports single/multi-select, custom trigger and option renderers, top/bottom position. | | NavixPaginatedList | Virtualized 1D list with sliding window pagination. Items are rendered only within the visible window + buffer. When showScrollbar (or renderScrollbar) is enabled, mounts a NavixScroll as a focusable child — arrowing into the scrollbar transfers focus to it; arrowing back returns focus to the active item. Accepts isItemDisabled, activeKey, disabled, outerClassName, innerClassName, slotClassName. | | NavixPaginatedGrid | Virtualized 2D grid with sliding window pagination. Supports horizontal (column-major) and vertical (row-major) orientation. Embeds NavixScroll as a focusable child when a scrollbar is shown. Accepts isItemDisabled, activeKey, disabled, outerClassName, innerClassName, slotClassName. | | NavixScroll | Focusable scrollbar. When focused, arrow keys (matching orientation) move by arrowStep (default 1) and PageUp/PageDown move by pageStep (default 1). Renders a draggable track + thumb by default; override with renderScrollbar. Used internally by NavixPaginatedList / NavixPaginatedGrid but reusable on its own. | | NavixStepper | Focusable single-value stepper. Arrow keys (matching orientation) call onChange with the new value. render accepts 'scrollbar', 'progress', or a render function ({ focused, status, value, min, max, step }) => ReactNode for full visual control. Accepts feedbackTimeout (ms before status resets), trackStyle, and thumbStyle for built-in renderer styling. | | NavixMultiLayer | Full-screen video player shell. Renders a baseLayer beneath up to four directional panels (left, right, up, down). Only one panel is active at a time. Accepts onPrev/onNext for channel switching, onTogglePlay for Enter/play/pause when no panel is open, zapBanner shown for 2s after channel change, notification for persistent or transient overlays, and panelTimeout to auto-close inactive panels. | | NavixMultiLayerPanelProps | Props passed to each panel render function: fKey, close, panelState, panelRootProps (spread on the visible panel root for hover-to-stay-open), and all BaseComponentProps. | | BaseComponentProps | Shared interface all components extend: fKey, disabled, focusOnRegister, onFocus, onBlurred, onRegister, onUnregister, onEvent. |


Core Concepts

FocusNode and the tree

Every focusable element — a button, a row, a grid — is a FocusNode. Nodes form a tree. Each node tracks its children and which child is currently active via activeChildId.

FocusTree.root
└─ NavixVerticalList "app"
   ├─ NavixHorizontalList "menu"
   │  ├─ "menu-Home"        ← isDirectlyFocused
   │  └─ "menu-Settings"
   └─ NavixHorizontalList "row-0"
      ├─ "card-0"
      └─ "card-1"

isFocused is true for every node on the active path from root to leaf.
isDirectlyFocused is true only for the deepest active leaf.

Event routing

Events travel down then up:

  1. FocusTree receives a NavEvent and calls root.handleEvent(event).
  2. Each node forwards the event to its activeChild first.
  3. If the child returns false, the node calls its own onEvent.
  4. If onEvent returns true (consumed), propagation stops.
  5. If false, the event bubbles to the parent.

A deeply nested button consumes enter, while left/right fall through to the row, and up/down fall through further to the page layout.

InputManager and gestures

InputManager translates raw keydown/keyup into NavEvent objects:

interface NavEvent {
  action: string; // 'left' | 'right' | 'up' | 'down' | 'enter' | 'back' | custom
  type: 'press' | 'longPress' | 'doublePress';
}

Default key mappings:

| Action | Keys | | -------------- | --------------------------------- | | left | ArrowLeft | | right | ArrowRight | | up | ArrowUp | | down | ArrowDown | | enter | Enter (long-press after 500 ms) | | back | Escape | | play | MediaPlay | | pause | MediaPause | | play_pause | MediaPlayPause, Space | | program_up | PageUp | | program_down | PageDown |

Behaviors

Behaviors implement IFocusNodeBehavior and wire node.onEvent to handle navigation logic. They contain no DOM manipulation and no rendering.

new ListBehavior(node, 'horizontal'); // left/right between children
new ListBehavior(node, 'vertical'); // up/down between children
new GridBehavior(node, columns); // 4-direction, stops at row edges
new ButtonBehavior(node, { onPress, onLongPress, onDoublePress });
new ExpandableBehavior(node); // enter expands, back collapses
new InputBehavior(node); // enter starts editing, enter/back stops editing
new PaginatedListBehavior(
  node,
  orientation,
  totalCount,
  visibleCount,
  threshold,
  onChange, // (newIndex: number, newOffset: number, refocusItem: boolean) => void
  indexForKey, // (key: string) => number
  isItemDisabled, // (index: number) => boolean
  scrollbarKey, // string | null — key of the embedded NavixScroll child, if any
);
new PaginatedGridBehavior(
  node,
  orientation,
  totalCount,
  rows,
  columns,
  threshold,
  onChange, // (newIndex: number, newOffset: number, refocusItem: boolean) => void
  indexForKey, // (key: string) => number
  isItemDisabled, // (index: number) => boolean
  scrollbarKey, // string | null — key of the embedded NavixScroll child, if any
);
new ScrollBehavior(node, {
  orientation, // 'horizontal' | 'vertical'
  onDelta, // (delta: -1 | 1) => void — fired on arrow keys
  onPageDelta, // (delta: -1 | 1) => void — fired on PageUp/PageDown
});
new StepperBehavior(node, {
  orientation, // 'horizontal' | 'vertical'
  long, // boolean — also fire on long-press
  double, // boolean — also fire on double-press
  onChange, // (delta: -1 | 1, type: 'single' | 'long' | 'double') => void
});

refocusItem in the paginated onChange callback distinguishes item-driven navigation from scrollbar-driven setPage calls. When false, focus stays on the scrollbar; when true, focus is moved to the newly active item. scrollbarKey lets the behavior recognise the embedded NavixScroll child so it stays pinned at index 0 and is excluded from item-index bookkeeping.

All behaviors fire lifecycle hooks when core calls them:

| Hook | When | | --------------------- | ------------------------------------- | | onRegister | Node registered with parent | | onUnregister | Node removed from parent | | onFocus | Node became the directly focused leaf | | onBlurred | Node lost direct focus | | onChildRegistered | A child node was registered | | onChildUnregistered | A child node was unregistered | | onActiveChildChanged| Active child changed | | onConsumedByChild | A descendant consumed the event (return value: void) |

You can skip built-in behaviors entirely and return a plain object from createBehavior for custom navigation logic.

BaseComponentProps and callbacks

All React components extend BaseComponentProps:

interface BaseComponentProps {
  fKey: string;
  disabled?: boolean;
  focusOnRegister?: boolean;
  onFocus?: (key: string) => void;
  onBlurred?: (key: string) => void;
  onRegister?: (key: string) => void;
  onUnregister?: (key: string) => void;
  onEvent?: (event: NavEvent) => boolean;
}

key is the fKey of the component that fired the event — useful for identifying which item in a list changed state.

focusOnRegister calls requestFocus() automatically when the node registers with its parent — useful for giving a specific component initial focus on mount. When multiple components have focusOnRegister={true}, the last one to register wins.

NavixExpandable and focus trapping

When a node expands, ExpandableBehavior walks the full tree and collapses all other expandables — except nodes on the current active path (ancestors). This allows nested expandables where an outer container stays open while an inner one opens.

While expanded, all events are trapped inside the node. back collapses and releases the trap.


Getting Started

bun add @navix/react
# or
npm install @navix/react

1. NavixScope

Wraps your entire app. Creates the FocusTree, attaches keyboard listeners to document, and provides the root focus node via context. All Navix components must be inside a NavixScope.

import { NavixScope, NavixHorizontalList, NavixButton } from '@navix/react';

function App() {
  return (
    <NavixScope>
      <NavixHorizontalList fKey="row">
        <NavixButton fKey="play" onClick={() => console.log('Play')}>
          Play
        </NavixButton>
        <NavixButton fKey="info" onClick={() => console.log('Info')}>
          Info
        </NavixButton>
      </NavixHorizontalList>
    </NavixScope>
  );
}

Pass tailwind-merge's twMerge via mergeClassName for conflict-free Tailwind class merging:

import { twMerge } from 'tailwind-merge';
<NavixScope mergeClassName={twMerge}>...</NavixScope>;

Custom key mappings via inputConfig:

<NavixScope inputConfig={{
  actions: {
    left:  { keys: ['ArrowLeft', 'KeyA'] },
    right: { keys: ['ArrowRight', 'KeyD'] },
    up:    { keys: ['ArrowUp', 'KeyW'] },
    down:  { keys: ['ArrowDown', 'KeyS'] },
    enter: { keys: ['Enter', 'Space'], longPress: true, longPressMs: 600 },
    back:  { keys: ['Escape'] },
  }
}}>

2. NavixHorizontalList / NavixVerticalList

Container nodes that route arrow key navigation between their children. NavixHorizontalList responds to left/right, NavixVerticalList to up/down.

<NavixVerticalList fKey="page">
  <NavixHorizontalList fKey="row-0">
    <NavixButton fKey="a">A</NavixButton>
    <NavixButton fKey="b">B</NavixButton>
  </NavixHorizontalList>
  <NavixHorizontalList fKey="row-1">
    <NavixButton fKey="c">C</NavixButton>
    <NavixButton fKey="d">D</NavixButton>
  </NavixHorizontalList>
</NavixVerticalList>

Both accept style, focusedStyle, className, focusedClassName — a wrapper div is rendered only when any of these are provided.

3. NavixGrid

Fixed 2D grid. Navigates in all four directions, stopping at row edges on left/right.

<NavixGrid fKey="channel-grid" columns={5}>
  {channels.map((ch) => (
    <NavixButton key={ch.id} fKey={ch.id} onClick={() => tune(ch)}>
      {ch.name}
    </NavixButton>
  ))}
</NavixGrid>

4. NavixButton

Leaf focusable. Fires onClick on both mouse click and keyboard enter. Supports onLongPress and onDoublePress for keyboard-only gestures.

Three ways to style focused state:

// 1. focusedStyle
<NavixButton fKey="play" style={{ background: '#222' }} focusedStyle={{ background: '#4fc3f7' }}>
  ▶ Play
</NavixButton>

// 2. focusedClassName (use with Tailwind)
<NavixButton fKey="play" className="bg-card px-4 py-2 rounded" focusedClassName="ring-2 ring-primary">
  ▶ Play
</NavixButton>

// 3. Render prop — full control
<NavixButton fKey="play">
  {({ focused }) => <div style={{ color: focused ? '#fff' : '#888' }}>▶ Play</div>}
</NavixButton>

style is always applied as an inline style — it wins over className when both target the same property (standard browser behavior).

5. NavixSwitch

Controlled boolean toggle built on NavixButton. Enter or click flips checked and calls onChange. Render prop receives (checked, focused).

const [enabled, setEnabled] = useState(false);

<NavixSwitch fKey="notifications" checked={enabled} onChange={setEnabled}>
  {(checked, focused) => (
    <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
      <span>Notifications</span>
      <span
        style={{
          padding: '3px 12px',
          borderRadius: 20,
          background: checked ? '#1e3a2e' : '#1a1a2e',
          color: checked ? '#4caf7d' : '#555',
          outline: focused ? '1px solid #4fc3f7' : 'none',
        }}
      >
        {checked ? 'On' : 'Off'}
      </span>
    </div>
  )}
</NavixSwitch>;

6. NavixInput

Two-state text input. Idle mode: navigable like any other component. Editing mode: focus is trapped, nav events are swallowed, and the native <input> element receives keyboard input. Enter starts editing, Enter or back stops editing.

const [query, setQuery] = useState('');

// Default input element — style/className props applied to the wrapper div
<NavixInput
  fKey="search"
  value={query}
  onChange={setQuery}
  style={{ border: '1px solid #333', borderRadius: 4, padding: '5px 10px' }}
  focusedStyle={{ borderColor: '#4fc3f7' }}
  editingStyle={{ borderColor: '#4fc3f7', boxShadow: '0 0 0 2px rgba(79,195,247,0.15)' }}
/>

// Custom render — full control
<NavixInput fKey="search" value={query} onChange={setQuery}>
  {({ editing, inputRef, stopEditing }) => (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
      <SearchIcon />
      <input
        ref={inputRef}
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={(e) => { if (e.key === 'Escape') stopEditing(); }}
      />
    </div>
  )}
</NavixInput>

7. NavixExpandable

Two-state container. Enter expands, back collapses. Only one expandable can be open at a time — opening one closes all others. While expanded, focus is trapped inside.

<NavixExpandable fKey="card">
  {({ isExpanded, directlyFocused, collapse }) => (
    <div
      style={{
        border: directlyFocused ? '2px solid #4fc3f7' : '2px solid transparent',
      }}
    >
      <div>Title</div>
      {isExpanded && (
        <NavixHorizontalList fKey="card-actions">
          <div style={{ display: 'flex' }}>
            <NavixButton
              fKey="card-play"
              focusedStyle={{ background: '#4fc3f7' }}
              onClick={() => {
                play();
                collapse();
              }}
            >
              ▶ Play
            </NavixButton>
            <NavixButton
              fKey="card-info"
              focusedStyle={{ background: '#4fc3f7' }}
              onClick={collapse}
            >
              ℹ Info
            </NavixButton>
          </div>
        </NavixHorizontalList>
      )}
    </div>
  )}
</NavixExpandable>

8. NavixDropdown

Single or multi-select dropdown built on NavixExpandable. Options are navigated with up/down. Enter selects, back closes.

const [resolution, setResolution] = useState(['1080p']);

<NavixDropdown
  fKey="resolution"
  options={[
    { value: '4k', label: '4K' },
    { value: '1080p', label: '1080p' },
    { value: '720p', label: '720p' },
  ]}
  value={resolution}
  onChange={setResolution}
  placeholder="Select..." // text shown when nothing is selected, default 'Select...'
  maxVisible={5}
  position="bottom"
/>;

9. NavixPaginatedList

Virtualized horizontal or vertical list. Only items within the visible window + buffer are mounted. The window slides when focus reaches the threshold position from either edge.

<NavixPaginatedList
  fKey="row"
  orientation="horizontal" // 'horizontal' | 'vertical', default 'horizontal'
  items={movies} // T[] — full item array
  visibleCount={6} // how many items are visible at once
  threshold={1} // positions from edge before window slides
  gap={12} // gap between slots in px
  buffer={2} // extra items rendered outside visible window
  // disabled: true skips keyboard nav for this index; item still renders
  // isItemDisabled={(index) => movies[index]?.unavailable ?? false}
  // activeKey={selectedItem.id} // jump on mount/change; write-only intent prop
  // disabled={false} // prevent this entire list from receiving focus
  // showScrollbar={true} // mounts a NavixScroll as a focusable child
  // renderScrollbar={(props) => <MyScrollbar {...props} />}
  renderItem={(item, fKey, index, disabled) => (
    <MovieCard fKey={fKey} item={item} disabled={disabled} />
  )}
  outerStyle={{ padding: '12px 4px' }}
  slotStyle={{ alignItems: 'stretch' }}
/>

10. NavixPaginatedGrid

Virtualized 2D grid. Pagination moves one slice at a time along the main axis.

<NavixPaginatedGrid
  fKey="grid"
  orientation="horizontal" // 'horizontal': column-major | 'vertical': row-major
  items={channels}
  rows={4}
  columns={6}
  threshold={1}
  gap={8}
  buffer={1}
  // isItemDisabled={(index) => channels[index]?.locked ?? false}
  // activeKey={selectedItem.id} // jump on mount/change; write-only intent prop
  // disabled={false} // prevent this entire grid from receiving focus
  showScrollbar={true} // mounts NavixScroll as a focusable child
  // renderScrollbar={(props) => <MyScrollbar {...props} />}
  renderItem={(item, fKey, index, disabled) => (
    <ChannelCard fKey={fKey} item={item} disabled={disabled} />
  )}
  outerStyle={{ height: 'calc(90vh - 120px)' }}
/>

Horizontal orientation layout (column-major):

col 0      col 1      col 2
[item 0]   [item 4]   [item 8]
[item 1]   [item 5]   [item 9]
[item 2]   [item 6]   [item 10]
[item 3]   [item 7]   [item 11]

Left/right moves between columns (pagination axis). Up/down moves within a column (stops at edges).

Orientation values:

  • horizontal — column-major layout, paginates left/right.
  • vertical — row-major layout, paginates up/down.
  • autoHorizontal — behaves like horizontal when there are enough items to fill the grid (items.length >= rows * columns); otherwise falls back to vertical so a partially filled grid lays out as a single row instead of a single column. Useful when item count is unknown at design time but you want the "full grid" case to look like a horizontal pager. Note: if items can grow past the threshold dynamically, the layout will flip and existing items will reflow — prefer plain horizontal for lazy-loaded lists.

The orientation prop accepts 'horizontal', 'vertical', or 'autoHorizontal'.

11. NavixScroll

Focusable scrollbar. Used internally by NavixPaginatedList and NavixPaginatedGrid to expose page navigation as a real focus target — the arrow key opposite to the list's main axis transfers focus from the active item to the scrollbar (the item truly blurs, no more "two focused things" effect), and the reverse direction returns focus to the previously active item.

You can also use NavixScroll on its own, e.g. as the scrollbar of a custom virtualized list.

const [page, setPage] = useState(0);

<NavixScroll
  fKey="my-scrollbar"
  orientation="horizontal"
  page={page}
  pageCount={pages.length}
  arrowStep={1} // pages per arrow press
  pageStep={5} // pages per PageUp/PageDown press
  onPageChange={setPage}
  // renderScrollbar={(props) => <MyScrollbar {...props} />}
/>;

The default visual is a draggable track + thumb. Override with renderScrollbar(props), which receives { scrollMode, page, pageCount, orientation, onPageChange } (scrollMode reflects whether the scrollbar itself is the directly-focused leaf).

12. NavixStepper

Focusable single-value stepper. Arrow keys along orientation call onChange(value) with the clamped result. render accepts 'scrollbar', 'progress', or a render function for full visual control.

const [volume, setVolume] = useState(40);

<NavixStepper
  fKey="volume"
  orientation="horizontal"
  value={volume}
  min={0}
  max={100}
  step={2}
  long={true} // also fires while Enter/arrow is held
  double={false} // also fires on double-press
  onChange={setVolume}
  render="scrollbar" // or 'progress' or ({ focused, status, value, min, max, step }) => ReactNode
  feedbackTimeout={300} // ms before StepperStatus resets to natural after a step
  // trackStyle — custom CSSProperties for the track element
  // thumbStyle — custom CSSProperties for the thumb/fill element
/>;

13. Focus lifecycle callbacks

Every component accepts onFocus, onBlurred, onRegister, onUnregister, and focusOnRegister. All receive the fKey of the component that fired the event:

<NavixButton
  fKey="play"
  focusOnRegister={true}
  onRegister={(key) => console.log(key, 'mounted')}
  onFocus={(key) => console.log(key, 'focused')}
  onBlurred={(key) => console.log(key, 'blurred')}
  onUnregister={(key) => console.log(key, 'unmounted')}
  onClick={() => play()}
>
  ▶ Play
</NavixButton>

14. NavixMultiLayer

Full-screen video player shell with directional panels. Each direction (left, right, up, down) optionally renders a panel — only one is open at a time. Focus is trapped inside the active panel; back closes it and returns focus to the base layer.

Channel switching (program_up / program_down keys) calls onNext / onPrev. If the callback returns true (meaning the channel actually changed), the zapBanner is shown for 2 seconds. State like the current channel and paused/playing mode lives outside NavixMultiLayer — use onEvent or your own callbacks to manage it.

const [player, setPlayer] = useState({ channels, current, paused: false });

<NavixMultiLayer
  fKey="player"
  onExitRequest={() => setPlayer(null)}
  onNext={() => {
    // advance channel, return true if changed
    const next = getNextChannel();
    if (!next) return false;
    setPlayer((p) => ({ ...p, current: next }));
    return true;
  }}
  onPrev={() => {
    const prev = getPrevChannel();
    if (!prev) return false;
    setPlayer((p) => ({ ...p, current: prev }));
    return true;
  }}
  onTogglePlay={() => setPlayer((p) => ({ ...p, paused: !p.paused }))}
  baseLayer={() => (
    <VideoElement src={player.current.url} paused={player.paused} />
  )}
  zapBanner={() => <ZapBanner channel={player.current} />}
  notification={() => (player.paused ? <PauseOverlay /> : null)}
  left={(props) => <AudioSubtitlesPanel {...props} />}
  right={(props) => (
    <ChannelListPanel
      {...props}
      channels={player.channels}
      current={player.current}
    />
  )}
  up={(props) => <NotificationsPanel {...props} />}
  down={(props) => (
    <ControlsPanel
      {...props}
      paused={player.paused}
      onToggle={() => setPlayer((p) => ({ ...p, paused: !p.paused }))}
    />
  )}
  panelTimeout={4000}
/>;

panelTimeout (default 4000 ms) auto-closes the active panel after inactivity. Any navigation event from within a panel resets the timer, and the timer is paused while the pointer is hovering the panel (see panelRootProps below). Set to a large value to disable auto-close.

Panel render functions receive NavixMultiLayerPanelProps:

type NavixMultiLayerPanelState = 'opening' | 'open' | 'closing';

interface NavixMultiLayerPanelRootProps {
  ref: (el: HTMLElement | null) => void;
  onMouseEnter: () => void;
  onMouseLeave: () => void;
}

interface NavixMultiLayerPanelProps extends BaseComponentProps {
  close: () => void; // close the panel programmatically
  panelState: NavixMultiLayerPanelState; // use for CSS entry/exit transitions
  panelRootProps: NavixMultiLayerPanelRootProps; // spread on the visible panel root
}

panelState cycles 'opening' → 'open' → 'closing' as the panel mounts and unmounts. Use it to drive CSS transitions:

left={(props) => (
  <div
    {...props.panelRootProps}
    style={{
      transform:
        props.panelState === 'open'
          ? 'translateX(0)'
          : 'translateX(-100%)',
      transition: 'transform 250ms ease',
    }}
  >
    <AudioSubtitlesPanel {...props} />
  </div>
)}

transitionDuration (default 250 ms) controls how long NavixMultiLayer waits before unmounting the closing panel. Set it to match your CSS transition duration.

panelRootProps — keeping the panel open under the mouse

panelRootProps bundles a ref and onMouseEnter / onMouseLeave handlers that you must spread onto the actual visible panel element so NavixMultiLayer can pause the auto-close timer while the pointer is hovering it.

// ✅ Correct — spread on the visible 260px-wide panel
left={(props) => (
  <div
    {...props.panelRootProps}
    style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 260 }}
  >
    …
  </div>
)}
// ❌ Wrong — don't spread on a fullscreen backdrop / overlay wrapper.
// Every pixel would count as "hovering the panel" and the timer would
// never fire.
left={(props) => (
  <div {...props.panelRootProps} style={{ position: 'absolute', inset: 0 }}>
    <div style={{ position: 'absolute', left: 0, width: 260 }}>…</div>
  </div>
)}

Two subtleties worth knowing about:

  • Keyboard-opened with the cursor already inside. When the panel mounts under a stationary pointer, the browser does not fire mouseenter. The ref callback handles this by checking el.matches(':hover') on the next frame and pausing the timer accordingly — so this case Just Works as long as panelRootProps is spread on the right element.
  • Don't omit panelRootProps if you have several panels stacked / animated wrappers. It is the wrapper closest to the visible panel surface that should receive these props; outer animation containers are usually wrong because they fill the screen during transforms.

Mouse users can also open panels by hovering near the edge of the base layer. triggerSize (default 200 px) sets the width/height of the invisible hover zone on each edge. hoverDelay (default 100 ms) sets how long the pointer must dwell in the zone before the panel opens.

15. Custom focusable with useFocusable

For components that need direct access to focus state without using a built-in component.

import { useFocusable, ButtonBehavior } from '@navix/react';
import type { FocusNode } from '@navix/react';

function MenuItem({ fKey, label, onPress, onFocus }) {
  const { directlyFocused, focusSelf } = useFocusable(
    fKey,
    { onFocus: (key) => onFocus?.(key) },
    (node: FocusNode) => new ButtonBehavior(node, { onPress }),
  );

  return (
    <div
      onMouseEnter={focusSelf}
      style={{ color: directlyFocused ? '#fff' : '#888' }}
    >
      {label}
    </div>
  );
}

You can also return a plain object instead of a class instance:

const { directlyFocused, focusSelf } = useFocusable(fKey, {}, () => ({
  onEvent: (e) => {
    if (e.action === 'enter' && e.type === 'press') {
      onPress();
      return true;
    }
    return false;
  },
}));

If no createBehavior is provided, a minimal default behavior is attached automatically so callbacks are always wired correctly.


Running the Demo

bun install
bun run dev

Opens a TV-style streaming UI at http://localhost:5173.

Navigate with arrow keys. Enter to select. Backspace or Escape to go back.

| Tab | Component | Description | | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Home | NavixPaginatedList + NavixMultiLayer | Three paginated rows — movies, series, live channels. Selecting an item opens a NavixMultiLayer player with left (audio/subtitles), right (channel list), up (notifications), and down (controls) panels. | | Movie | NavixPaginatedGrid | Movies in a paginated 4×6 grid with trailer preview simulation. | | Series | NavixHorizontalList | Classic horizontal shelves. | | Live | NavixGrid | Fixed grid of live channels. | | Options | NavixExpandable + NavixDropdown + NavixSwitch + NavixInput | Settings modal with persistent state. Contains dropdowns, a boolean toggle, and a text input — all keyboard navigable. |


Key Design Decisions

ID-based focus trackingactiveChildId stores the node's id string, not an array index. Reordering, inserting, or removing children does not corrupt focus state.

Bottom-up event return — Returning true from onEvent consumes the event. Returning false lets it bubble. Components handle what they know about and ignore the rest.

Behaviors are decoratorsuseFocusable sets node.behavior to the return value of createBehavior, or to a default { onEvent: () => false } if none is provided. The tree calls lifecycle hooks (onRegister, onUnregister, onFocus, onBlurred, onChildRegistered, onChildUnregistered) without knowing which behavior is attached.

Callbacks always wireduseFocusable attaches a minimal default behavior when no createBehavior is provided, ensuring onFocus/onBlurred/onRegister/onUnregister callbacks are always delivered regardless of whether the component uses a built-in behavior.

Exclusive expandExpandableBehavior walks the tree on expand to close all other expandables. Ancestors on the active path are skipped. The rule holds regardless of how components are composed.

Pagination decoupled from DOMPaginatedListBehavior and PaginatedGridBehavior track activeIndex and viewOffset independently of node.children. Navigation decisions happen before React re-renders. onChildRegistered is used to hand focus to newly mounted children after the render cycle completes.

Stable virtual keysNavixPaginatedList and NavixPaginatedGrid generate item keys via useMemo tied to the items array reference. Keys are stable across scroll — items do not remount when the window slides. When items changes (new array reference), all keys regenerate and children remount cleanly.

Item-level lifecycle via child callbacks — Paginated components do not proxy item events. Each child component handles its own onRegister/onFocus/onBlurred/onUnregister lifecycle directly via useFocusable or the component's own props.

React StrictMode safeNavixScope handles the double-mount cycle. FocusNode.register guards against duplicate registration.