Flex-grid types
Types shared by several @roku-sdk/flex-grid exports, or used by none of them directly
import type {
CardLayoutContext,
CardPool,
CellRenderProps,
CellVAlign,
FlexGridClippingConfig,
FlexGridConfig,
FlexGridConfigCore,
FlexGridHandle,
FocusConfig,
FocusedRowAttachmentOptions,
FocusHighlightConfig,
FocusHighlightProps,
FocusState,
FocusStrategyBase,
FocusStrategyPlugin,
GridCapabilities,
GridCapability,
GridFocusInternals,
GridKeyHolder,
GridLayersInternals,
GridPlugin,
GridPluginEntry,
GridPluginInternals,
GridRowsInternals,
GridScrollInternals,
GridStore,
GridViewport,
HFocusStyleName,
HorizontalFocusAxisConfig,
HorizontalScrollHooks,
InitGridPlugin,
KeyDelegate,
KeyValue,
PerfGridPlugin,
PerfHandle,
PerfPlugin,
PluginCellTemplate,
PoolBufferConfig,
PooledNodeVM,
PoolOps,
PoolSizeResult,
Position,
PositionValue,
RemoteKey,
RowCardsWrap,
RowCardsWrapContext,
RowYOffsetProvider,
SmoothScrollConfig,
TxCounts,
VerticalFocusAxisConfig,
VerticalFocusOptions,
VerticalFocusState,
VerticalFocusStrategyPlugin,
VerticalScrollHooks,
VerticalStrategyConfig,
VFocusStyleName,
} from "@roku-sdk/flex-grid";
import type { FixedFocusYParams } from "@roku-sdk/flex-grid/geometry";
import type {
HStrategyExtension,
HStrategyFocusContext,
HStrategyMatcher,
HStrategyScrollContext,
} from "@roku-sdk/flex-grid/compose-h-strategy";Each is documented here once and linked from every page that uses it.
Types
CardLayoutContext
Context passed to optional per-card layout callbacks (e.g. zoom-focus plugin).
| Name | Type | Description |
|---|---|---|
cellHeight | number | |
cellWidth | number | |
col | number | |
focused | boolean | |
row | number | |
templateId | string |
CardPool
| Name | Type | Description |
|---|---|---|
isWarmupComplete | boolean | True after warmupFromConfig or markWarmupComplete has run. |
size | number | |
acquire | (templateType: string) => PooledNodeVM<T> | |
bindHost | (ref: ContainerRef) => void | |
dispose | () => void | |
markWarmupComplete | () => void | |
release | (vm: PooledNodeVM<T>, templateType: string) => void | |
setGridKey | (key: object) => void | |
setNodeOwner | (owner: Owner | null) => void | |
warmup | (templateType: string, count: number) => void | |
warmupFromConfig | (config: Record<string, number>) => void |
CellRenderProps
Props passed to the getCellComponent factory for each visible pool node.
| Name | Type | Description |
|---|---|---|
col | number | Data column index of this cell. |
focused | Accessor<boolean> | Reactive accessor — true when this cell has focus. |
gridKey? | object | Stable per-grid instance key — use as a WeakMap key when cell templates need per-grid plugin state (e.g. spotlight deferred-col bridges). |
height | number | Cell height in pixels (from cellSize or rowCellSize). |
isActive | Accessor<boolean> | Reactive accessor — true while this pool node is on-screen (acquired). False when parked in the pool. Templates can use this to skip animations (e.g. snap scale instead of tweening off-screen nodes). |
item | T | undefined | The item for this cell, or undefined when the row is still loading. |
row | number | Data row index of this cell. |
setFocusBoundary | (focusId: object | null) => void | See below. |
setKeyDelegate | (delegate: KeyDelegate | null) => void | Register a KeyDelegate for this cell. See below. |
width | number | Cell width in pixels (from cellSize or rowCellSize). |
CellRenderProps.setFocusBoundary
Register this cell's own FocusBoundary identity, for composite cells that would rather receive keys through the focus chain than through a KeyDelegate.
While this cell is the focused cell, the grid moves its own boundary's virtual focus to the identity registered here. Keys then reach the cell's boundary first, and the grid's navigation runs only when that boundary declines — a key the cell resolves internally (zone move, inner grid movement, Select) never reaches the grid, and a key it has no neighbour for falls through to move the grid. This inverts the KeyDelegate contract, where the delegate runs before navigation and has to return false to hand the key back.
Requires the grid to be in boundary mode (a focusId on the grid config); without one there is no boundary whose focus to move. Pass null to unregister.
The identity must be registered by a real FocusBoundary (or useFocusable) in the cell's subtree. Unlike CellRenderProps.setKeyDelegate, this registration is not cleared when the pool node is recycled: a pool node's subtree mounts once and is reused, so its boundary — and therefore its identity — is stable for the life of the grid, and clearing it would leave the cell unreachable after its first reuse. Register once and let it stand.
Because that boundary outlives the item it first mounted for, drive its contents from row / col / item rather than from mount-time values, and prefer rememberLastFocus={false} so sub-focus resets when the cell is reused. rememberLastFocus remembers per pool node, not per item, so a recycled node reopens on whatever the previous item last focused. To remember per item, store the last sub-focus on the item and derive initialFocus from it.
Shared pools are supported when all grids share the same root FocusBoundary. The grid rebinds the cell boundary's parent to the displaying grid before giving it focus.
CellRenderProps.setKeyDelegate
Register a KeyDelegate for this cell. The delegate is called before the grid's own navigation handler so the cell can intercept keys. Return false from handleKey to fall through to the grid. Set null to unregister (e.g., when the cell collapses). The delegate is automatically cleared when the pool node is recycled.
Only movement / RemoteKey.Select keys claimed by the grid reach the delegate — see KeyDelegate.handleKey.
CellVAlign
Vertical alignment for cells with per-item heights within a row.
type CellVAlign = "top" | "center" | "bottom";FixedFocusYParams
Describes viewport and row geometry for a fixed vertical focus strategy.
Row indices are zero-based integers and all dimensions are in pixels. Empty content uses a zero row count; row accessors are not called for it.
Also exported as FixedFocusYParams from @roku-sdk/flex-grid (deprecated).
Deprecated: FixedFocusYParams. Import from @roku-sdk/flex-grid/geometry instead. The root export will be removed in the next major version.
Example
const geometry: FixedFocusYParams = {
containerY: 50, viewportHeight: 200, numRows: 4,
getRowY: row => row * 100, getRowHeight: () => 100
};| Name | Type | Description |
|---|---|---|
containerY | number | Y offset where the grid container starts on screen, in pixels. |
getRowHeight | (r: number) => number | Returns the height in pixels for a zero-based row index. |
getRowY | (r: number) => number | Returns the content Y position in pixels for a zero-based row index. |
numRows | number | Number of rows in the grid. |
viewportHeight | number | Height of the viewport, a size measured from containerY, matching the grid's clip rect. The bottom edge in screen space is containerY + viewportHeight. |
FlexGridClippingConfig
Clipping options — when enabled, width and height are required.
type FlexGridClippingConfig = { enableClipping: false; height: number; width: number } | { enableClipping: true; height: number; width: number };FlexGridConfig
type FlexGridConfig<T> = FlexGridConfigCore<T> & FlexGridClippingConfig;FlexGridConfigCore
Configuration fields shared by clipped and unclipped grids.
| Name | Type | Description |
|---|---|---|
allowJumpOnEmpty? | boolean | Accessor<boolean> | When false (default), jumpTo waits until rows 0..targetRow are present in the store and the target row has at least one item. When true, jump immediately; unloaded rows render placeholders in the viewport window. |
captureKeyPress? | boolean | Boundary mode only (focusId set). See below. |
cellSize | [number, number] | |
cellSpacing? | [number, number] | |
cellVAlign? | CellVAlign | Vertical alignment for cells with per-item heights. Default: "bottom". |
colWidths? | number[] | |
debugViewport? | boolean | Debug: draw a rectangle outline around the grid viewport boundary. Useful for visualizing the scroll/windowing region. Default: false. |
debugViewportColor? | string | Color of the debug viewport outline (hex RGBA). Default: "0x00FF00FF" (green). |
declineUnhandledNavigation? | boolean | Boundary mode only (focusId set). See below. |
focus | FocusConfig | Focus and scroll strategies for horizontal and vertical axes. |
focusId? | object | Identity object registering this grid as a nested FocusBoundary focusable inside an enclosing (root) FocusBoundary. See below. |
getCellComponent | (templateId: string) => Component<CellRenderProps<T>> | |
getCellTemplateId? | (cellData: T | undefined, row: number) => string | |
handleAdditionalKeyEvents? | RemoteKeyName[] | See below. |
id? | string | SceneGraph id for the grid's root group, so the grid is identifiable when inspecting the UI tree (MCP app-ui, probe-node). Internal ge* node ids keep their own auto-generated per-instance prefix and are unaffected. |
initialFocus? | [number, number] | |
jumpTo? | [number, number] | Accessor<[number, number] | undefined> | Declarative focus/scroll target. See below. |
numCols | number | |
numRows | number | |
onEdgeReached? | (edge: Position) => void | Fires when the user was already at a grid boundary and pressed further — the cue to hand focus to a neighbouring region. Never fires on wrap. See Position. |
onFocusChanged? | (row: number, col: number, item: T | undefined) => void | |
onItemSelected? | (row: number, col: number, item: T | undefined) => boolean | Fires on OK for the focused cell. Return true to consume the key; return false to decline so a parent grid can run its own onItemSelected (nested mode via forwardKeyEvent). When omitted, OK is treated as declined (bubbles). |
onJumpToDataNeeded? | (targetRow: number, targetCol: number) => void | Called when a jump is deferred because rows 0..targetRow are not yet in the store. App should prefetch that range; grid retries on storeVersion bump. Fires at most once per deferred [targetRow, targetCol] until the jump succeeds or the target changes (no repeat calls on each storeVersion bump). |
onUnhandledAdditionalKey? | (key: RemoteKeyName, press: boolean) => void | See below. |
plugins? | GridPlugin<T>[] | Tree-shakeable grid plugins — bob, row-titles, focus-highlight, perf, and custom extensions. See below. |
pool? | CardPool<T> | External shared pool. When provided, the grid binds the host and applies poolBuffer warmup on mount (same as the internal pool path). Caller owns pool.dispose(). |
poolBuffer? | PoolBufferConfig | |
rowCellWidths? | number[] | Per-row cell widths for focus highlight sizing. If provided, overrides cellSize[0] for focus width. |
rowHeights? | number[] | |
rowHGap? | number[] | Per-row horizontal gap between cells. Falls back to cellSpacing[0] when omitted. |
rowNumCols? | number[] | Per-row column count override. When provided, rows[r] has rowNumCols[r] logical columns. Rows whose item count equals rowNumCols[r] are treated as "full" (and wrap in fixedFocusWrap). Falls back to numCols when not provided. |
smoothScroll? | boolean | |
smoothScrollConfig? | { focusSettleEase: "linear" | "outCubic"; holdDelay: number; msPerStep: number } | |
translation? | [number, number] | |
viewportBuffer? | { bottom: number; left: number; right: number; top: number } | See below. |
FlexGridConfigCore.captureKeyPress
Boundary mode only (focusId set). When true, a key this grid consumed on the key-down is bound to the grid, so its key-up is delivered here no matter where focus has moved in the meantime.
Smooth scroll is the reason it exists. The grid arms its hold timer on the press and only settles when it sees the release, but a glide moves virtual focus as it travels: cross a row whose cell registered its own boundary via setFocusBoundary, and the key-up is offered to that cell first. A cell that owns the key on both edges consumes it there, the grid never learns the hold ended, and the glide runs to the end of the data.
This is not limited to the directions. Capture keys off "the grid consumed this press", so it covers every key the grid's boundary claims: the four directions, Select (OK), Fwd / Rev, and everything in FlexGridConfigCore.handleAdditionalKeyEvents. For those, the visible effect is that if focus moved between the press and the release, the release comes back to the grid rather than descending to the newly focused cell. With FlexGridConfigCore.declineUnhandledNavigation on, a press the grid declined is not captured, so only keys it actually acted on are bound.
A cell that declines the key-up does not need this — that path already works, and is what the in-repo examples rely on. Turn it on when the grid holds rows whose cells claim movement keys, or when you would rather not depend on every cell template getting the decline right.
A bound key is released when the grid loses focus, so handing focus to another region mid-hold does not leave the key claimed — which also bounds the case where the key-up never arrives at all. Nothing is synthesized either way.
Implemented by captureKeyPress on the boundary the grid renders; see IFocusBoundaryProps.captureKeyPress in @roku-sdk/navigation for the exact semantics.
FlexGridConfigCore.declineUnhandledNavigation
Boundary mode only (focusId set). When true, keys the grid's navigation did not act on are declined instead of swallowed, so they bubble to whatever encloses the grid — a parent cell's own FocusBoundary, or the app's root boundary.
A grid's boundary otherwise consumes every movement key it claims, whether or not focus moved, which leaves an inner grid unable to hand its top / bottom / right edges to the grid around it: onEdgeReached fires, but the key is already gone. Declining removes the need for the out-of-band edge flag that pattern otherwise requires.
Consumption is judged the same way as bare mode's forwardKeyEvent: true when the focused cell's delegate consumed the key, when focus moved, or when onItemSelected returned true. That includes OK — with this on, an onItemSelected returning false reaches the enclosing region instead of being dropped.
Off by default: a top-level grid that declines its edge keys would start handing them to the app's root boundary, which is a behaviour change for existing apps.
FlexGridConfigCore.focusId
Identity object registering this grid as a nested FocusBoundary focusable inside an enclosing (root) FocusBoundary. When provided, the grid owns its movement keys (up/down/left/right/OK) through that boundary and hands region focus back to siblings via FlexGridConfigCore.onEdgeReached + setFocus.
Omit for a nested/bare grid (e.g. a grid inside another grid's cell): no boundary is created and keys arrive only via FlexGridHandle.forwardKeyEvent (driven by the parent cell's KeyDelegate).
FlexGridConfigCore.handleAdditionalKeyEvents
Extra keys to route into the grid's key pipeline, on top of the built-in movement and selection keys (BOUNDARY_KEYS: RemoteKey.Left / Right / Up / Down / Select / Fwd / Rev).
Listed keys are added to the grid's nested FocusBoundary claimed-key set and are offered to the focused cell's KeyDelegate first, matching the normal pipeline order. The grid itself never navigates on these keys.
If the delegate declines (returns false), the key is re-thrown to the enclosing boundary via FlexGridConfigCore.onUnhandledAdditionalKey so opting a key in never traps it.
Also honored by FlexGridHandle.forwardKeyEvent so nested/bare grids get the same behavior.
Treat this as static configuration. The claimed-key set is fixed when the grid's FocusBoundary is created. Via defineFlexGrid the array participates in the remount dependency list, so a content change remounts the grid; calling createFlexGrid directly there is no remount machinery at all and a later change has no effect. If the set needs to vary, opt in to the union of every key you might need and decide per-event inside the KeyDelegate.
FlexGridConfigCore.jumpTo
Declarative focus/scroll target. Reactive when passed as an Accessor. First application to a non-(0,0) position snaps (no tween); later changes animate. Supersedes initialFocus when both are set.
Dedup: the internal jump effect skips when the clamped target equals the last successfully applied declarative jump. Re-setting the same [row, col] after the user navigates away with the D-pad is a no-op. Use FlexGridHandle.jumpTo for unconditional re-jumps, or clear the accessor to undefined before re-assigning.
FlexGridConfigCore.onUnhandledAdditionalKey
Called when a key listed in FlexGridConfigCore.handleAdditionalKeyEvents was claimed by the grid's boundary but declined by the focused cell's KeyDelegate.
Because the grid's boundary claims these keys, they no longer reach the enclosing boundary's onUnhandledKey on their own. Use this hook to run the app-level fallback (the behavior the key would have had if it were never opted in).
Only fires in top-level (focusId) mode. In bare/nested mode the same condition is surfaced by FlexGridHandle.forwardKeyEvent returning false.
Fires for both press and release, mirroring the raw boundary events — guard on press if the action should run once per keypress.
Captured once at creation (like FlexGridConfigCore.onEdgeReached and FlexGridConfigCore.onItemSelected), so a later identity change is not picked up. Read changing state from a signal inside the callback rather than closing over it.
FlexGridConfigCore.plugins
Tree-shakeable grid plugins — bob, row-titles, focus-highlight, perf, and custom extensions.
Scale-on-focus is a template concern — wrap cell templates with withZoomFocus from @roku-sdk/flex-grid/zoom-focus instead of a plugin.
FlexGridConfigCore.viewportBuffer
Viewport buffer for row/column windowing (all values in pixels).
- top: extra pixels to render above viewport (default: 500)
- bottom: extra pixels to render below viewport (default: 500)
- left: extra pixels to render left of viewport (default: 500)
- right: extra pixels to render right of viewport (default: 500)
FlexGridHandle
| Name | Type | Description |
|---|---|---|
element | Element | The JSX element to render |
focusedCol | () => number | |
focusedItem | () => T | undefined | |
focusedRow | () => number | |
store | GridStore<T> | Direct reference to the underlying store. See below. |
forwardKeyEvent | (key: RemoteKeyName, press: boolean) => boolean | |
jumpTo | (row: number, col: number, animate?: boolean) => boolean | |
resetRowPosition | (row: number) => void | |
setNumRows | (n: number) => void | |
setRowGeometry | (patch: { rowCellWidths: number[]; rowHeights: number[]; rowHGap: number[]; rowNumCols: number[] }) => void | |
setRowHeight | (row: number, height: number) => void | |
setRowHeights | (heights: number[]) => void | |
updateRows | (startRow: number, rows: T[] | undefined[]) => void |
FlexGridHandle.store
Direct reference to the underlying store. Reading is always safe.
Writing to store.rows bypasses the emptiness bookkeeping behind the loaded-empty row collapse, so a row published that way keeps its full height even when set to []. Publish through FlexGridHandle.updateRows / FlexGridHandle.setNumRows instead.
FocusConfig
Focus and scroll behavior for both axes — pass on createFlexGrid({ focus }).
| Name | Type | Description |
|---|---|---|
horizontal | HorizontalFocusAxisConfig | |
vertical | VerticalFocusAxisConfig |
FocusedRowAttachmentOptions
Options for GridRowsInternals.attachToFocusedRow.
| Name | Type | Description |
|---|---|---|
localY? | (row: number) => number | Local Y (px) for the node inside the focused row slot's nodeRef; e.g. getRowHeight(row) to sit under the row's cards, or a negative height to sit above them. Default 0. |
FocusHighlightConfig (@roku-sdk/flex-grid)
Deprecated.
Legacy root type; import FocusHighlightConfig from @roku-sdk/flex-grid/focus-highlight instead. Will be removed from the public exports in the next major version.
Legacy focus-highlight configuration retained for compatibility.
| Name | Type | Description |
|---|---|---|
borderWidth? | number | |
color? | string | |
cornerRadius? | number |
FocusHighlightProps
| Name | Type | Description |
|---|---|---|
height | number | Height of the currently-focused cell. |
width | number | Width of the currently-focused cell. |
x | number | X position of the focus highlight in screen coordinates. |
y | number | Y position of the focus highlight in screen coordinates. |
FocusState
State passed to horizontal strategy functions on each focus update.
| Name | Type | Description |
|---|---|---|
col | number | Current (virtual) focused column index. May exceed numCols for wrap strategies. |
numCols | number | Total number of columns in the grid store (global, not per-row). For the actual item count of the current row, see rowItemCount. |
row | number | Row index this state is computed for. |
rowItemCount? | number | Actual number of items in the current row. May be less than numCols when rows have variable item counts. Defaults to numCols if not provided. |
screenWidth | number | Screen width in pixels (typically 1920). |
FocusStrategyBase
Base config shared by all horizontal focus strategies.
| Name | Type | Description |
|---|---|---|
focusOffset | number | X offset from the left screen edge where the focused item appears. |
FocusStrategyPlugin
Plugin interface for horizontal focus strategies. Each strategy is a separate module; only imported strategies end up in the bundle.
| Name | Type | Description |
|---|---|---|
name | string | |
computeFocusX | (state: FocusState, config: TConfig, cumulativeX: number[]) => number | |
computeScrollX | (state: FocusState, config: TConfig, cumulativeX: number[]) => number | |
resolveColIndex? | (virtualCol: number, numCols: number) => number | |
usesDirectScrollX? | (state: FocusState) => boolean |
GridCapabilities
Checks statically declared behavior before a plugin mounts duplicate chrome. The lookup includes every plugin's provides declarations, including the caller. It is fixed at grid construction and is not a reactive accessor.
| Name | Type | Description |
|---|---|---|
has | (capability: "focusVisual") => boolean | True when some plugin in plugins[] declared capability in its provides. |
GridCapability
Behaviour a plugin declares it owns, so other plugins can stand down instead of drawing the same thing twice. Declared statically via provides on the plugin object (read before any init runs, so it never depends on plugin order).
focusVisual; a plugin draws the focused cell's visual treatment.
createFocusHighlightPluginsuppresses its own node when something else provides it.
No first-party plugin declares focusVisual today: this is a third-party extension point for plugins that draw their own focus chrome. Cell templates cannot declare capabilities, so a template that owns the visual (via withZoomFocus, say) has the app pass renderNode: false to createFocusHighlightPlugin instead. Both routes reach the same outcome; the highlight still feeds per-cell focused to the engine, it just does not draw.
type GridCapability = "focusVisual";GridFocusInternals
Provides the focused item and geometry for focus-following plugin visuals. Read position, item and size accessors inside effects or JSX so visuals update as focus moves; values read once during init are only snapshots.
| Name | Type | Description |
|---|---|---|
cellHeight | () => number | Target height of the focused cell (not tweened). |
cellWidth | () => number | Target width of the focused cell (not tweened). |
col | () => number | Focused column index (data col, not virtual). |
highlightLocalX | () => number | X translation (content-space, inside the scrolling content container) for the focus highlight. |
highlightLocalY | () => number | Y translation (content-space, inside the scrolling content container) for the focus highlight, before row Y offsets. Add rows.rowYOffset(focus.row()) to follow a row offset that pushes the focused row's cards down. |
item | () => T | undefined | Data item at the current focus position. |
notifySettleEnd | () => void | Called by the focus-highlight plugin's position tween when it stops and no smooth scroll is in progress. Clears settling and fires the vertical focus-settle-end scroll hook. |
row | () => number | Focused row index. |
settleEase | "linear" | "outCubic" | Ease used for the focus-highlight glide when a held smooth scroll releases. |
settleStop | () => [number, number][] | Candidate stop used to restart the highlight glide with a non-linear settle ease, in the vector proposedStops form: a one-entry list holding the [x, y] destination, so both components settle. Empty when no settle is pending. |
settling | () => boolean | True while the focus highlight glides to the focused item after a held smooth scroll releases. |
setVisibility | (accessor: () => boolean) => void | Push the focus-visibility accessor to the grid. The engine reads it to clear per-cell focused signals (in-cell outlines, zoom scale) while internal row/col focus is preserved. Defaults to always-visible when no focus-highlight plugin is present. |
GridKeyHolder
Mutable holder so external pools can bind gridKey after warmup.
type GridKeyHolder = {
current?: object;
};| Name | Type | Description |
|---|---|---|
current? | object |
GridLayersInternals
Mounts plugin-owned components above, below or alongside the grid's content. Use scroll layers for content-space visuals and mountOverlay for an overlay outside scrolling content. Read refs and other grid accessors inside effects or JSX; the viewport object contains fixed construction-time geometry.
| Name | Type | Description |
|---|---|---|
mountOverlay | (overlay: Component) => () => void | Mount a reactive overlay component in the grid's main render tree. Use for plugin-owned nodes and for tweens targeting nodes inserted imperatively. Pass a Solid component (not a render callback) so signal reads stay tracked. Returns a dispose function called on plugin cleanup. |
mountScrollLayerContent | (layer: "front" | "back", overlay: Component) => () => void | Mount a reactive overlay into a scroll layer that inherits the grid scroll. Idempotently requests the layer. Returns a dispose function called on plugin cleanup. Use for the focus-highlight node. |
requestScrollBehindLayer | () => void | Idempotent; mounts the behind layer on next render. |
requestScrollFrontLayer | () => void | Idempotent; mounts the front layer on next render. |
scrollBehindGroupRef | () => ContainerRef | null | Behind stacking layer behind the main content. Opt-in via requestScrollBehindLayer(). |
scrollFrontGroupRef | () => ContainerRef | null | Front stacking layer. Opt-in via requestScrollFrontLayer(). |
scrollGroupRef | () => ContainerRef | null | Main scroll layer; row slots and default plugin inserts. |
viewport | GridViewport | The grid's viewport. Scroll-layer content at x = -viewport.x sits on the left edge of the grid's parent. |
GridPlugin
Plugin entry accepted by FlexGridConfig.plugins. Holds two kinds:
-
Lifecycle (
InitGridPlugin<T>) — bob, row-titles, focus-highlight, custom plugins.
Implementinit(internals)to mount UI or register scroll hooks.
When writing custom plugins, extend or returnInitGridPlugin<T>,
notGridPlugin<T>—GridPluginis the array-element union type. -
Perf (
PerfPlugin) — passcreatePerfPlugin().
Extracted before engine/nav creation byresolveGridPlugins.
Scale-on-focus is a template concern — use withZoomFocus from @roku-sdk/flex-grid/zoom-focus on cell templates instead of a plugin.
Example
// Implementing a custom init plugin:
import type { InitGridPlugin, GridPluginInternals } from "@roku-sdk/flex-grid";
function createMyPlugin<T>(): InitGridPlugin<T> {
return {
init(internals: GridPluginInternals<T>) { ... }
};
}
// Passing plugin kinds:
plugins: [
createPerfPlugin({ output: "both" }),
createMyPlugin(),
]type GridPlugin<T> = GridPluginEntry<T>;GridPluginEntry
All plugin kinds accepted in FlexGridConfig.plugins.
type GridPluginEntry<T = unknown> = InitGridPlugin<T> | PerfGridPlugin;GridPluginInternals
Grid state and registration hooks supplied to InitGridPlugin.init. Use these namespaces to register chrome, follow focus or coordinate animation without accessing the engine implementation. The engine exists during init, but engineReady() becomes true only after every plugin has initialized. All accessors are reactive; read them inside effects or JSX props. The namespace objects themselves are stable, so const { rows, focus } = internals at the top of init is safe.
| Name | Type | Description |
|---|---|---|
capabilities | GridCapabilities | What other plugins in plugins[] declared they own. |
engineReady | () => boolean | True once the engine has been initialised. |
focus | GridFocusInternals<T> | Focus position, focused-cell geometry, and focus-highlight glide state. |
gridKey | object | Stable per-grid instance key; share with cell templates via CellRenderProps.gridKey for per-grid plugin state (WeakMap lookups). |
layers | GridLayersInternals | Scroll layers and overlay mounting. |
nodeIdPrefix | string | Auto-generated per createFlexGrid (fg0_, fg1_, ...); matches engine row-slot ids. Prefix any fixed SceneGraph id in plugin JSX so nested grids stay unique. |
rows | GridRowsInternals<T> | Row data, geometry, and per-row extension points. |
scroll | GridScrollInternals | Scroll state and lifecycle hook registration. |
GridRowsInternals
Provides row geometry and registration hooks for row chrome or custom layout. Read reactive accessors such as count, rowYOffset and snapping inside effects or JSX. Row renderers receive a recycled slot's reactive row accessor; do not cache its current value when registering the renderer.
| Name | Type | Description |
|---|---|---|
attachToFocusedRow | (node: () => unknown, options: FocusedRowAttachmentOptions) => () => void | Re-parent a plugin-owned node into the focused row's slot, so it tracks that row's Y tween and scroll exactly with no follow tween of its own. The node moves only when the focused row changes. Mount the node itself first (e.g. via layers.mountOverlay) and pass its ref accessor. Returns a dispose function; also disposed on plugin cleanup. |
count | () => number | Total number of rows (reactive). |
getRowHeight | (r: number) => number | Height of row r. |
getRowNumCols | (r: number) => number | Logical column count for row r. |
getRowY | (r: number) => number | Y position of row r inside the scroll container (content-space), before row Y offsets. |
registerCardLayout | (handler: (vm: PooledNodeVM<T>, ctx: CardLayoutContext) => void) => void | Register a per-card layout callback; invoked by the engine on every layout pass after position/focus updates. See below. |
registerRowChrome | (render: (row: Accessor<number>) => unknown, layer: "front" | "back") => void | Register a per-row chrome renderer. See below. |
registerRowYOffset | (provider: RowYOffsetProvider) => () => void | Register a reactive per-row Y offset; the engine adds it to each row's target Y, and the per-row Y tween glides rows to the new pitch when the offset changes. See below. |
requestLayout | () => void | Re-run card layout for visible pool nodes (e.g. after plugin gap geometry changes). |
reservedRowTop | number | Height reserved above every row for plugin chrome such as titles, already inside the row gap. |
rowYOffset | (row: number) => number | Sum of every registered row Y offset for row at the current focus (reactive, including to providers registered after the caller first read it). |
snapping | () => boolean | True while the grid snaps its row slots instead of gliding them; after a non-animated jumpTo, or when a row collapsing or expanding reflows the rows below it. A plugin node that moves with the rows should snap too while this holds. |
store | GridStore<T> | Live store reference; rows and dirtyRows. |
GridRowsInternals.registerCardLayout
Register a per-card layout callback; invoked by the engine on every layout pass after position/focus updates. Handlers run in registration order (call order during init).
Call this synchronously from init (not from createEffect, queueMicrotask, or other deferred work), or the first layout pass may run before your handler is registered.
GridRowsInternals.registerRowChrome
Register a per-row chrome renderer. render receives a reactive row accessor; read it inside JSX props or effects, never destructure at the top level. The output is mounted into every row slot's back or front chrome container and re-used as slots are recycled (the row accessor updates reactively). Must be called synchronously from init, exactly once per layer; calling it multiple times for the same layer adds additional chrome without removing previous mounts. layer: "front" (default) renders in front of cards; "back" renders behind them.
GridRowsInternals.registerRowYOffset
Register a reactive per-row Y offset; the engine adds it to each row's target Y, and the per-row Y tween glides rows to the new pitch when the offset changes. Use it to open space between rows (e.g. a metadata block below the focused row).
Drive it from a reactive scope: read the plugin's own createMemo inside the provider so the engine re-lays-out when the offset changes. See RowYOffsetProvider for the purity requirement. Returns a dispose function; also disposed on plugin cleanup.
GridScrollInternals
Coordinates plugin animation with navigation, smooth scrolling and settling. Read scroll-state accessors inside effects or JSX. Register lifecycle callbacks synchronously in init; callbacks run on events rather than as reactive effects.
| Name | Type | Description |
|---|---|---|
isSmoothScrollingX | () => boolean | True while horizontal smooth scroll is in progress. |
overrideActive | () => boolean | True while a programmatic scroll override (e.g. jumpTo) is in flight; size tweens use a linear ease. |
registerHorizontalHooks | (hooks: HorizontalScrollHooks) => void | Register horizontal scroll callbacks; invoked when the row X tween settles. Every plugin's hooks fire, in registration order. |
registerVerticalHooks | (hooks: VerticalScrollHooks) => void | Register vertical navigation callbacks; invoked by the nav controller. Every plugin's hooks fire, in registration order. |
smoothScrolling | () => boolean | True while a held smooth scroll (horizontal or vertical) is in progress. |
tweenDuration | () => number | Current per-step tween duration (seconds). |
GridStore
Flat mutable data store for the grid.
rows[rowIndex]holds the items for that row.T[]→ row has data, renders cells normallyundefined→ row is loading; placeholder/skeleton shown; height preserved[]→ row has no items; collapses to zero height
dirtyRowstracks which rows changed since the last engine tick.versionis bumped on every mutation and drives the monolithic reactive memo.forceFullRelayoutis set bysetNumRowsso the engine cannot take the
data-only short-circuit until the next full layout pass clears it.
| Name | Type | Description |
|---|---|---|
dirtyRows | Set<number> | |
forceFullRelayout | boolean | |
rows | T[] | undefined[] | |
version | number |
GridViewport
The grid's viewport, in the coordinates config.translation is given in.
| Name | Type | Description |
|---|---|---|
clipped | boolean | True when enableClipping cuts everything the grid draws to this rect. |
height | number | Viewport height in pixels. |
width | number | Viewport width in pixels. |
x | number | Left edge in pixels in the parent coordinate system. |
y | number | Top edge in pixels in the parent coordinate system. |
HFocusStyleName
type HFocusStyleName = "fixedFocus" | "floatingFocus" | "fixedFocusWrap" | "zoomFocus";HorizontalFocusAxisConfig
Horizontal focus axis: strategy plugin plus its tunables.
| Name | Type | Description |
|---|---|---|
options? | Partial<TConfig> | |
strategy | FocusStrategyPlugin<TConfig> |
HorizontalScrollHooks
Horizontal scroll lifecycle callbacks that plugins can register.
| Name | Type | Description |
|---|---|---|
onHorizontalNav? | () => void | Called when a left/right key press moves focus to a new column. |
onHorizontalScrollEnd? | () => void | Called when the focused row's X scroll tween finishes and H smooth scroll is idle. |
onHorizontalSmoothScrollStart? | () => void | Called when held horizontal smooth scroll starts. |
HStrategyExtension
Partial override applied on top of a base horizontal focus strategy. Provide only the hooks you need; unmatched states delegate to the base.
| Name | Type | Description |
|---|---|---|
matches | HStrategyMatcher | Returns true when this extension owns the current focus state. |
computeFocusX? | (ctx: HStrategyFocusContext<TConfig>) => number | |
computeScrollX? | (ctx: HStrategyScrollContext<TConfig>) => number |
HStrategyFocusContext
H Strategy Focus Context configuration and callbacks.
| Name | Type | Description |
|---|---|---|
baseFocusX | number | Value from the base strategy before extensions run. |
config | TConfig | Configuration of the base focus strategy. |
cumX | number[] | Cumulative column positions in pixels. |
state | FocusState | Current focus indices and navigation direction. |
HStrategyMatcher
Returns true when an extension applies to the current focus state.
type HStrategyMatcher = (state: FocusState) => boolean;HStrategyScrollContext
H Strategy Scroll Context configuration and callbacks.
| Name | Type | Description |
|---|---|---|
baseScrollX | number | Value from the base strategy before extensions run. |
config | TConfig | Configuration of the base focus strategy. |
cumX | number[] | Cumulative column positions in pixels. |
state | FocusState | Current focus indices and navigation direction. |
InitGridPlugin
Registers custom grid chrome and behavior through GridPluginInternals.
Pass a plugin object in createFlexGrid({ plugins: [plugin] }). The grid reads static contributions (cellTemplate, reservedRowTop, wrapRowCards and provides) during construction, before the pool and row slots are created. It calls init once per mounted grid, in plugins array order, after creating the engine and navigation controller. engineReady() is still false during init and becomes true after all plugins have initialized.
Register layout and scroll hooks synchronously inside init, before the first layout pass. init returns nothing: returning a disposer does not register cleanup. Use onCleanup from @roku-sdk/solid for subscriptions, timers and disposers returned by registration or mounting methods. Effects created in init belong to the grid's reactive owner and stop when that owner is disposed. Mounted components also unmount with the grid. Calling a returned disposer removes that registration or mount earlier; register it with onCleanup when you want explicit teardown. Shared external pools remain caller-owned.
Capabilities describe behavior supplied by the complete plugin set, not permission to access internals. Declare ownership statically with provides; the grid collects all declarations before calling any init. Query internals.capabilities.has("focusVisual") to avoid duplicate focus chrome, regardless of plugin order. focusVisual is currently the only capability. A plugin querying its own declared capability also receives true. It does not enforce exclusive ownership: cooperating plugins decide whether to stand down.
Read accessors inside effects or JSX properties to track changes. Namespace objects are stable, but caching focus.row() in init captures only its initial value. This example draws an outline in content coordinates so it inherits scrolling; it adds any plugin-provided row offset to the highlight position.
Example
import { Rectangle } from "@roku-sdk/components";
import { onCleanup } from "@roku-sdk/solid";
import { createFlexGrid, fixedFocusStrategy, type InitGridPlugin } from "@roku-sdk/flex-grid";
const outline: InitGridPlugin<string> = {
provides: ["focusVisual"],
init(internals) {
const { focus, rows, layers } = internals;
const dispose = layers.mountScrollLayerContent("front", () => (
<Rectangle
translation={[
focus.highlightLocalX(),
focus.highlightLocalY() + rows.rowYOffset(focus.row())
]}
width={focus.cellWidth()}
height={4}
color="0xffffffff"
/>
));
onCleanup(dispose);
}
};
const grid = createFlexGrid<string>({
numRows: 1,
numCols: 2,
cellSize: [240, 160],
width: 1280,
height: 720,
focus: {
horizontal: { strategy: fixedFocusStrategy },
vertical: { strategy: fixedFocusStrategy }
},
plugins: [outline],
getCellComponent: () => () => <Rectangle width={240} height={160} />
});
grid.updateRows(0, [["One", "Two"]]);
// Render grid.element inside your app's reactive component.| Name | Type | Description |
|---|---|---|
cellTemplate? | PluginCellTemplate<T> | A pooled cell template this plugin supplies (e.g. a loading placeholder). Read once at grid construction, before the pool exists; must not change at runtime. |
provides? | "focusVisual"[] | Behaviours this plugin owns, so other plugins can stand down (e.g. a plugin that scales the focused card declares "focusVisual" and the focus-highlight plugin then skips its own node). Collected before any init runs, so it does not depend on plugins[] order. |
reservedRowTop? | number | Static pixels this plugin reserves above every row's cards for its chrome (e.g. a row title). Summed across plugins and folded into the engine's row pitch so cellSpacing[1] stays the residual gap between a row's chrome and the next row. Read once at grid construction; must not change at runtime. |
wrapRowCards? | RowCardsWrap | Wraps every row slot's cards in a plugin-owned node. Read once at grid construction, since row slots are allocated before any init runs; must not change at runtime. |
init | (internals: GridPluginInternals<T>) => void |
KeyDelegate
| Name | Type | Description |
|---|---|---|
handleKey | (key: RemoteKeyName, press: boolean) => boolean | |
onActivate? | () => void | |
onDeactivate? | () => void |
KeyValue
A firmware key name as delivered by onKeyEvent(). Prefer the RemoteKey enum over string literals — its members are RCButton-aligned and its values are the runtime strings (notably RemoteKey.Select === "OK"). Plain strings remain assignable for forward compatibility with keys the enum does not name.
type KeyValue = RemoteKeyName;PerfGridPlugin
Perf instrumentation plugin; discovered by kind before engine/pool creation.
type PerfGridPlugin = PerfPlugin;PerfHandle
| Name | Type | Description |
|---|---|---|
layoutCount | Accessor<number> | Layout-effect evaluations counted in the most recent transaction. |
poolOps | Accessor<PoolOps> | |
txCounts | Accessor<TxCounts> | |
warmupComplete | Accessor<boolean> |
PerfPlugin
| Name | Type | Description |
|---|---|---|
handle | PerfHandle | |
kind | "perf" | |
countAcquire | () => void | |
countLayout | () => void | |
countMiss | () => void | |
countRelease | () => void | |
dispose | () => void | |
keypressEnd | () => void | |
keypressStart | (key: string | RemoteKeyNameCompat) => void | |
markWarmupComplete | () => void |
PluginCellTemplate
A cell template contributed by a plugin.
The grid folds these into cell routing ahead of the app's own getCellTemplateId, so the template gets a real pool key: nodes recycle per template and poolBuffer[id] warms them like any app template. Resolved before createPool, since the pool needs the component lookup at construction time.
| Name | Type | Description |
|---|---|---|
component | Component<CellRenderProps<T>> | Component rendered for cells this template claims. |
id | string | Pool template key. Must not collide with a template the app returns from getCellTemplateId, or the two would share pooled nodes. |
matches | (cellData: T | undefined, row: number, col: number) => boolean | Decides whether this template owns a given cell. See below. |
PluginCellTemplate.matches
Decides whether this template owns a given cell. Called on every layout pass for every visible cell, so keep it cheap and free of side effects. Returning false defers to the next contributed template, and then to the app's getCellTemplateId.
row and col are the resolved data indices for the cell being laid out; col is the wrapped/virtualised column, matching the index used to read the item, so it is safe to route on. When several plugins contribute templates, the first match in plugins[] order wins.
PoolBufferConfig
Per-template pool warmup counts. Each key is a template ID; value is the number of nodes to pre-allocate.
type PoolBufferConfig = Record<string, number>;PooledNodeVM
Reusable cell instance and setters supplied by a card pool to the grid.
| Name | Type | Description |
|---|---|---|
element | unknown | JSX element — inserted into the render tree. |
focusBoundaryId | object | null | Focus-boundary identity registered by the cell template via CellRenderProps.setFocusBoundary. The grid parks its boundary's virtual focus here while this VM is the focused cell. |
focusRelay? | FocusRelay | null | Stable parent context for the cell subtree; null or absent when the node was created outside a FocusBoundary. Optional so a hand-rolled CardPool keeps compiling: a VM without one simply cannot follow a shared pool between grids. |
keyDelegate | KeyDelegate | null | Key delegate registered by the cell template via CellRenderProps.setKeyDelegate. |
templateType | string | Template type this node was created for — used by the pool for correct release routing. |
setCol | (c: number) => void | |
setFocused | (f: boolean) => void | |
setIsActive | (active: boolean) => void | |
setItem | (item: T | undefined) => void | |
setRow | (r: number) => void | |
setX | (x: number) => void | |
setY | (y: number) => void |
PoolOps
| Name | Type | Description |
|---|---|---|
acquires | number | |
misses | number | |
releases | number |
PoolSizeResult
Deprecated.
Pool sizing is managed by the grid implementation. The root export will be removed in the next major version.
| Name | Type | Description |
|---|---|---|
poolH | number | |
poolW | number | |
total | number |
Position
A boundary of the grid. Reported by FlexGridConfigCore.onEdgeReached when the user was already at that boundary and pressed further, which is the usual cue to hand focus to a neighbouring region.
Member values are the raw strings the callback has always received, so Position.Top === "top".
| Member | Value | Description |
|---|---|---|
Top | "top" | |
Bottom | "bottom" | |
Left | "left" | |
Right | "right" |
PositionValue
The type FlexGridConfigCore.onEdgeReached reports, named separately for handlers that annotate their parameter. An alias of Position, so a received value works either way: edge === Position.Top and edge === "top" both compile and both match, and a handler annotated (edge: "top" | "bottom" | "left" | "right") stays assignable. The one direction that does not typecheck is passing a bare literal into a parameter of this type — use the member.
type PositionValue = Position;RemoteKey
RCButton-aligned remote key names delivered by the firmware via onKeyEvent().
Enum member names follow the firmware RCButton identifiers. Enum values are the runtime key strings the app receives — NOT the ECP wire names used by tooling (e.g. ECP sends "Rev" but the app receives "rewind").
Note: Home is excluded because it is not delivered to the app.
| RCButton Enum | Runtime Key (RemoteKey value) | Availability |
|---|---|---|
| Back | "back" | Available on all remotes |
| Up | "up" | Available on all remotes |
| Down | "down" | Available on all remotes |
| Left | "left" | Available on all remotes |
| Right | "right" | Available on all remotes |
| Select | "OK" | Available on all remotes |
| InstantReplay | "replay" | Available on all remotes |
| Play | "play" | Available on all remotes |
| Rev | "rewind" | Available on all remotes |
| Fwd | "fastforward" | Available on all remotes |
| Info | "options" | Available on all remotes |
| Backspace | "backspace" | Only available on some remotes in select markets |
| Search | "search" | Only available on some remotes in select markets |
| Enter | "enter" | Only available on some remotes in select markets |
| A | "A" | Only available on some remotes in select markets |
| B | "B" | Only available on some remotes in select markets |
| PlayOnly | "playonly" | Only available on some remotes in select markets |
| Stop | "stop" | Only available on some remotes in select markets |
| Guide | "guide" | Only available on some remotes in select markets |
| ChannelUp | "channelup" | Only available on some remotes in select markets |
| ChannelDown | "channeldown" | Only available on some remotes in select markets |
| LastChannel | "lastchannel" | Only available on some remotes in select markets |
| Partner | "partner" | Only available on some remotes in select markets |
| Red | "red" | Only available on some remotes in select markets |
| Green | "green" | Only available on some remotes in select markets |
| Yellow | "yellow" | Only available on some remotes in select markets |
| Blue | "blue" | Only available on some remotes in select markets |
| Exit | "exit" | Only available on some remotes in select markets |
| TV | "tv" | Only available on some remotes in select markets |
| Pause | "pause" | Only available on some remotes in select markets |
| WakeWord | "wakeword" | Only available on some remotes in select markets |
| VirtualKeyboard | "virtualkeyboard" | Only available on some remotes in select markets |
Example
// Enum members (RCButton-aligned names):
onKey: { [RemoteKey.Select]: onPress(() => select()) }
// Legacy runtime string literals remain valid:
onKey: { OK: onPress(() => select()) }| Member | Value | Description |
|---|---|---|
Back | "back" | |
Up | "up" | |
Down | "down" | |
Left | "left" | |
Right | "right" | |
Select | "OK" | |
InstantReplay | "replay" | |
Play | "play" | |
Rev | "rewind" | |
Fwd | "fastforward" | |
Info | "options" | |
Backspace | "backspace" | |
Search | "search" | |
Enter | "enter" | |
A | "A" | |
B | "B" | |
PlayOnly | "playonly" | |
Stop | "stop" | |
Guide | "guide" | |
ChannelUp | "channelup" | |
ChannelDown | "channeldown" | |
LastChannel | "lastchannel" | |
Partner | "partner" | |
Red | "red" | |
Green | "green" | |
Yellow | "yellow" | |
Blue | "blue" | |
Exit | "exit" | |
TV | "tv" | |
Pause | "pause" | |
WakeWord | "wakeword" | |
VirtualKeyboard | "virtualkeyboard" |
RowCardsWrap
Wraps a row slot's cards in a plugin-owned node; the one place a plugin can put a node that is an ancestor of the cards, and so does not travel with the row's horizontal scroll.
Declared statically on the plugin, because row slots are allocated while the grid is constructed, before any init runs. Applied in plugins[] order, first entry outermost.
type RowCardsWrap = (context: RowCardsWrapContext) => JSX.Element;RowCardsWrapContext
What RowCardsWrap is handed for one row slot.
| Name | Type | Description |
|---|---|---|
cards | Element | The slot's cards node. Whatever the wrapper returns must contain it. |
row | Accessor<number> | Reactive index of the row this slot currently shows. |
screenHeight | number | Grid viewport height. |
screenWidth | number | Grid viewport width, for a wrapper needing a rect larger than anything on screen. |
RowYOffsetProvider
Extra Y (px) a plugin adds to a row's slot position, on top of its natural layout Y. Called for each row in the live window on every layout pass, and on each probe of the variable-row-height binary search, so it must be cheap.
Must be a pure function of (row, focusedRow) plus the plugin's own reactive reserve accessor. Reading any other grid signal (scroll position, column, ...) widens the dependency set of the row-window memos and the layout effect, causing extra relayouts.
type RowYOffsetProvider = (row: number, focusedRow: number) => number;SmoothScrollConfig
| Name | Type | Description |
|---|---|---|
easing? | "linear" | "outCubic" | |
holdDelay? | number | |
msPerStep? | number |
TxCounts
| Name | Type | Description |
|---|---|---|
createElement | number | |
insertNode | number | |
removeNode | number | |
setProperty | number | |
txTime | number | Wall-clock duration (ms) of the most recent render-thread transaction — measured from the transaction start (keypress if one fired first, otherwise the transactionStart hook) to the pre-commit hook. |
VerticalFocusAxisConfig
Vertical focus axis: strategy plugin plus its tunables.
| Name | Type | Description |
|---|---|---|
options? | VerticalFocusOptions | |
strategy | VerticalFocusStrategyPlugin |
VerticalFocusOptions
Tunables for a vertical focus axis.
| Name | Type | Description |
|---|---|---|
focusOffset? | number | Y offset from the top of the grid viewport where the focused row is pinned. |
VerticalFocusState
State passed to vertical strategy functions on each focus update.
⚠️ This object is a shared mutable scratch instance reused across calls. Implementations must NOT retain a reference to it — read all needed values synchronously within the callback and discard the reference.
| Name | Type | Description |
|---|---|---|
baseline? | number | Scroll baseline used by floating-focus style strategies. When omitted, strategies should use their initial landing baseline. |
numRows | number | |
row | number |
VerticalFocusStrategyPlugin
Customizes vertical scrolling and the focus highlight's screen position.
Set this object as focus.vertical.strategy; it is not an InitGridPlugin entry in plugins. Call geometry helpers with the same content coordinates used by the row accessors. Strategy state is reused by the engine, so consume it synchronously rather than retaining it. This strategy starts the focused row at the viewport top until the final rows need end-zone clamping.
Example
import type { VerticalFocusStrategyPlugin, VerticalFocusState,
VerticalStrategyConfig } from "@roku-sdk/flex-grid";
import { computeScrollYForRow } from "@roku-sdk/flex-grid/geometry";
const rowY = (row: number, config: VerticalStrategyConfig) =>
config.getRowY?.(row) ?? row * (config.rowPitch ?? 0);
const scrollY = (state: VerticalFocusState, config: VerticalStrategyConfig) =>
computeScrollYForRow(state.row, {
containerY: config.viewportTop ?? 0,
viewportHeight: config.viewportHeight,
numRows: state.numRows,
getRowY: row => rowY(row, config),
getRowHeight: row => config.getRowHeight?.(row) ?? config.rowPitch ?? 0
});
const topAlignedStrategy: VerticalFocusStrategyPlugin = {
name: "topAligned",
computeScrollY: scrollY,
computeFocusY: (state, config) => rowY(state.row, config) + scrollY(state, config)
};
// Pass { strategy: topAlignedStrategy } as focus.vertical.| Name | Type | Description |
|---|---|---|
name | string | |
computeFocusY | (state: VerticalFocusState, config: VerticalStrategyConfig) => number | |
computeScrollY? | (state: VerticalFocusState, config: VerticalStrategyConfig) => number |
VerticalScrollHooks
Vertical scroll lifecycle callbacks that plugins can register.
| Name | Type | Description |
|---|---|---|
onVerticalFocusSettleEnd? | () => void | Called when the focus highlight finishes gliding after a held vertical smooth scroll. |
onVerticalNav? | () => void | Called when a vertical key press moves focus to a new row. |
onVerticalScrollEnd? | () => void | Called when vertical scroll session ends (key release without smooth scroll, or after settle). |
onVerticalSmoothScrollStart? | () => void | Called when held vertical smooth scroll starts. |
VerticalStrategyConfig
Layout geometry forwarded by the engine to vertical strategy methods on every call. Mirrors the role focus.horizontal.options plays for horizontal strategies.
| Name | Type | Description |
|---|---|---|
focusOffset | number | Screen Y where the focused row is pinned (fixedFocusY) or top of grid (floatingFocusY). See below. |
getRowHeight? | (row: number) => number | Height of row r; used by floating-focus to detect off-screen overflow. |
getRowY? | (row: number) => number | Y position of row r inside the scroll container (content-space). |
rowPitch? | number | Default row pitch (height + gap) for uniform grids. Used as fallback when getRowY is absent. |
viewportHeight | number | Height of the viewport in pixels — a size, not a bottom edge. For a grid with a translation, this is the height of its band, so the band's bottom edge in screen space is viewportTop + viewportHeight. |
viewportTop? | number | Screen Y of the top of the viewport: the grid's own translation[1]. See below. |
VerticalStrategyConfig.focusOffset
Screen Y where the focused row is pinned (fixedFocusY) or top of grid (floatingFocusY).
Unlike the horizontal focusOffset, which is relative to the grid's container, this one is in the grid's parent space: the engine adds translation[1] before calling the strategy. That is because computeScrollY returns the scroll group's own Y translation, and the scroll group sits at translation[1] when unscrolled. Horizontally the scroll group stays at translation[0] and rows scroll inside it, so computeScrollX works relative to it. Changing either convention would break existing custom strategies, so viewportTop is passed instead to reconcile this value with viewportHeight.
VerticalStrategyConfig.viewportTop
Screen Y of the top of the viewport: the grid's own translation[1]. Defaults to 0, which is what an untranslated grid has and what a strategy written before this field existed effectively assumed.
focusOffset is in screen space and viewportHeight is a size, so a strategy needs this to put the two in one space — the space of the available room below the focus line is viewportTop + viewportHeight - focusOffset. Reading viewportHeight as if it were the bottom edge is what made a translated grid scroll its rows above its own clip rect and render blank.
VFocusStyleName
type VFocusStyleName = "fixedFocus" | "floatingFocus";Updated about 5 hours ago