🎯

Focus & navigation
focusable, on:key, and the cross-component LRUD focus system.

An automatic, cross-component geometric (up/down/left/right) focus system — no hand-written SetFocus()/onKeyEvent chasing required for ordinary directional navigation between components you didn't even write together.

focusable and on:key

focusable="true" (or a dynamic {expr}) registers an element with the app-wide focus registry — it needs an id. on:key[Key1,Key2,...]={call} binds a handler using Roku's own raw key strings (a * wildcard matches any key), with bubbling simulated up through nested on:key ancestors exactly like real DOM event bubbling. default-focus="true" marks a component's own natural entry point (at most one per component, must pair with a static focusable="true").

HomeScreen.thr (excerpt)
<Rectangle id="prompt" focusable="true" default-focus="true"
           color="0x3A3A3AFF" width="{promptWidth}" height="{promptHeight}"
           on:key[OK]="{goToSchedule()}">
  <Label id="promptLabel" text="{welcomeText}" />
</Rectangle>
ScrollFocusDemo.thr (excerpt)
<Rectangle id="tile" focusable="true" on:key[OK]="{selectTile(tile)}">
  <Label id="tileLabel" text="{tile.label}" />
</Rectangle>

Cross-component LRUD navigation

Arrow-key navigation searches the current component's own focusable content first, then widens to the whole app-wide registry — geometric, spatial-navigation-style scoring (genuine axis overlap, not a cone/angle guess). Holding a direction key accelerates repeat navigation automatically. A component declaring both scrollOffsetX/scrollOffsetY fields gets automatic scroll-into-view when focus moves to off-screen content within it:

ScrollFocusDemo.thr (excerpt)
field scrollOffsetX: float = 0
field scrollOffsetY: float = 0

<component>
<Rectangle id="viewport" clippingRect="{[0, 0, 1000, 500]}">
  <Group id="track" translation="{[-scrollOffsetX, -scrollOffsetY]}">
    {#each tiles as tile (tile.id)}
      <Rectangle id="tile" focusable="true" translation="{[tile.x, tile.y]}" .../>
    {/each}
  </Group>
</Rectangle>
</component>

RowList-style multi-item jump — jumpFocus

jumpFocus(<direction>, <count>, <press>) moves focus several registered candidates at once in one direction, instead of navigate()'s own single step — the same "up"/"down"/"left"/"right" vocabulary, repeated up to <count> times and stopping early at a real boundary (landing on the last reachable item instead of overshooting) — and, unlike an ordinary arrow-key press, a jump never crosses into a different component partway through: it stays confined to whichever component's own registered content the press started in, even once it runs out of further candidates there. It's deliberately not automatic the way arrow-key LRUD is — every chapter app in this project already reserves FAST-FORWARD/REWIND for chapter switching precisely because navigate() never touches those keys, so an author wires jumpFocus onto an on:key[...] binding instead — on the focusable element itself, or once on a plain wrapping container, since on:key bubbles from wherever focus currently sits up through its ancestor chain (no focusable needed on that container). <press> must be forwarded through unconditionally (never guarded by the caller's own if (press)) — a press jumps focus and arms the same hold-to-repeat timing arrow keys already use, a release stops it:

JumpFocusDemo.thr (excerpt)
<Group id="list" on:key[fastforward]="{jumpDown()}" on:key[rewind]="{jumpUp()}">
  {#each rows as row (row.id)}
    <Rectangle id="row" focusable="true">
      <Label id="rowLabel" text="{row.label}" />
    </Rectangle>
  {/each}
</Group>

private function jumpDown(key: string, press: boolean) {
  jumpFocus("down", 5, press)  ' <press> forwarded unconditionally — see note below
}
private function jumpUp(key: string, press: boolean) {
  jumpFocus("up", 5, press)
}

The vacuum rule

Automatically-chosen focus (on router mount, or after the focused node is destroyed) is applied only when nothing currently holds focus — it never takes focus away from somewhere it already is. This is what makes a persistent side menu keep focus across navigations instead of getting yanked into newly-mounted content every time. An explicit focus(<id>) deliberately overrides the rule, since the author asked for it by name.

focus("searchBox")  ' jump focus to one of THIS component's own descendants, by id

isFocused / isInFocusChain

Reserved, reactive read-only fields — synthesized only for a component that actually reads them (no cost for everyone else). isFocused means this component owns the focused element; isInFocusChain means the focused element is anywhere in its subtree (nested child components included) — a single-writer design makes two components reporting isFocused = true at once unrepresentable, not merely disallowed.

derived menuHighlight: string = pickHighlight(isFocused)

private function pickHighlight(focused: boolean): string {
  if (focused) {
    return "0x0057FFFF"
  }
  return "0x2A2A2AFF"
}
<Rectangle id="menu" color="{menuHighlight}">
{#if isInFocusChain}
  <Label id="activeBadge" text="active section" />
{/if}
</Rectangle>

Reference implementation — apps/focus-demo

Every mechanism on this page has a router-mounted, scaled chapter in apps/focus-demo — 7 chapters (/focusable-basics through /jump-focus), reachable with REWIND/FAST-FORWARD once compiled and sideloaded. One chapter, CrossSiblingRelayDemo, is deliberately kept hand-written (not compiled from .thr) — the project's one worked example of a hand-authored component used directly as a router route's own component:, composing three real .thr siblings and hand-wiring the cross-sibling focus(<id>)/relay pattern this page describes above. See findings/focus-demo-app.md for what each chapter covers and findings/demo-app-conventions.md for the app-structure convention it follows.

⚠️ Not (yet) supported

  • ○ Two elements at any nesting depth both statically focusable="true" — rejected at compile time as a provable ambiguity.
  • ○ A router-free app needs an explicit boot-time claim (a hand-written setup() calling into the focus manager) — nothing applies a proposed default focus into a vacuum on its own outside the router's automatic hand-off.
  • ○ A {#if:destroy} block whose newly-mounted content is a nested custom component still needs an explicit claimFocusIfVacant call to hand focus into it — unregistering the OUTGOING content is now automatic (generated teardown asks the focus manager to walk its own registry by ownership, not by what this component's own template scan can see), but nothing claims focus for the incoming content on its own.
  • ○ No DSL-exposed timing configuration for jumpFocus's own hold-to-repeat — it always reuses the fixed repeatTuning() constants arrow-key repeat already uses.

Exact grammar: GRAMMAR.md. Full feature status: docs/features.md.