ButtonRail

Updated: Sep 24, 2026
Use ButtonRail as the standard bottom action lane when actions might not fit across the available width. It owns a horizontal viewport, a scroll track synchronized with focus motion, fading gradients on whichever side is overflowing, and left/right focus navigation between its children.
A rail holds button-like children only: Button, QuickReplyButton, and ButtonDivider. It is not a tab strip, a filter bar, or a list header — use SubNavigationPager when the controls swap peer content.
Reach for ButtonRail when the action count, label length, or available width could cause overflow, and ButtonGroup when a small set always fits.

Usage

Dock the rail at the bottom of the display, outside the route’s scroll container, and let it reach both horizontal edges. Keep the xsmall spacing token below the buttons so focus and press motion have rubber-banding clearance.
import { Button, ButtonDivider, ButtonRail } from '@wearables-ui-toolkit/mrbd';

<div className="page">
  <div className="page-content">{content}</div>
  <div className="action-dock">
    <ButtonRail anchorIndex={1}>
      <Button icon={previousIcon} title="Previous" onClick={showPrevious} />
      <ButtonDivider />
      <Button icon={saveIcon} title="Save" onClick={save} />
      <ButtonDivider />
      <Button icon={nextIcon} title="Next" onClick={showNext} />
    </ButtonRail>
  </div>
</div>;
.page {
  display: grid;
  grid-template-rows: 1fr auto;
  height: 100%;
}

.action-dock {
  padding-bottom: var(--uit-spacing-xsmall);
}
Call updateScrollPosition() when mounted children change in a way the rail cannot observe. This recomputes its position from the updated layout:
import { useEffect, useRef } from 'react';
import { ButtonRail } from '@wearables-ui-toolkit/mrbd';
import type { ButtonRailHandle } from '@wearables-ui-toolkit/mrbd';

const railRef = useRef<ButtonRailHandle>(null);

useEffect(() => {
  railRef.current?.updateScrollPosition(false);
}, [availableActions]);

<ButtonRail ref={railRef}>{availableActions.map(renderAction)}</ButtonRail>;

Props

The full prop type is ButtonRailProps.
PropTypeDefaultDescription
children
ReactNode
—
The rail’s children. Required. Use button-like toolkit components only.
anchorIndex
number
—
Index of the child the rail tries to keep centered, counted over the rail’s focusable children only — non-focusable children such as ButtonDivider are skipped. Provide enough content on both sides of it — roughly half the rail width each way — otherwise the rail cannot hold the anchor in place. An index outside that range is ignored.
centerContentWhenSmallerThanWidth
boolean
true
Center the content when it is narrower than the rail. Ignored when anchorIndex is set.
centerFocusedView
boolean
false
Force the focused child to the center of the rail — a static focus cursor, including at the ends. Supersedes anchoring. Use only when the design explicitly calls for it.
onScrollChange
(scrollX: number, deltaX: number) => void
—
Called whenever the scroll offset changes. scrollX is positive when the content is scrolled to the right and negative when short content is centered; deltaX is the change since the previous notification.
onChildFocusChange
(focusedChild: HTMLElement \| null) => void
—
Called when a child gains focus, and with null when focus leaves the rail entirely.
className
string
''
Extra class on the rail viewport.
style
CSSProperties
—
Extra inline styles on the rail viewport.
ButtonRail does not spread arbitrary DOM attributes: className, style, and the imperative handle are the only ways to reach the rail element.

Imperative handle

ButtonRail forwards a ButtonRailHandle.
MethodDescription
skipAnimationForNextFocusMovement(): void
Sets a one-shot flag so the next focus-driven scroll jumps instead of animating. Call it immediately before moving focus programmatically; it clears itself after the next focus change.
resetScrollPositionIfNoFocusedChild(animated: boolean): void
Returns the rail to its default position, but only when no child currently holds focus. A no-op otherwise, because focus determines the rail position.
updateScrollPosition(animated: boolean): void
Recomputes and applies the scroll position for the last focused child. Call it after content or layout changes that the rail cannot observe on its own.
getElement(): HTMLDivElement \| null
Returns the rail viewport element, or null when unmounted.
import { useRef } from 'react';
import type { ButtonHandle, ButtonRailHandle } from '@wearables-ui-toolkit/mrbd';

const railRef = useRef<ButtonRailHandle>(null);
const primaryButtonRef = useRef<ButtonHandle>(null);

function focusPrimaryAction() {
  railRef.current?.skipAnimationForNextFocusMovement();
  primaryButtonRef.current?.getElement()?.focus();
}

Behavior

Scroll modes. With no anchor and content that overflows, focusing the first or last child scrolls to that end, and a child in the middle is only moved when it would fall outside the viewport or under an edge gradient — otherwise the rail stays put. Content that already fits is centered instead of following focus at all, unless you turn centerContentWhenSmallerThanWidth off, in which case it sits at the leading edge. anchorIndex distributes children proportionally around the anchor so the anchor sits at the center whenever the surrounding content allows. centerFocusedView pins the focused child to the center regardless of position, which is a stronger behavior and overrides anchoring. Do not combine anchorIndex and centerFocusedView.
Bounds. Ordinary movement is clamped to the content edges, so the rail never scrolls past its first or last child. centerFocusedView deliberately lifts that clamp, because centering an end child requires moving content beyond the edge.
Scrolling model. The rail is not a browser scroll container: it clips horizontally and translates an inner track, and it resets any browser scroll offset it picks up. Drive it through focus or the imperative handle rather than by setting scroll offsets on the element.
Motion. Focus-driven scrolling is synchronized with the focused button’s expansion. On the frame focus changes, the rail projects the layout every child will settle into — the width and scale each one takes in its final focused or default state — computes the destination from that projection, and then advances toward it using the focused button’s measured expansion progress. Scroll and expansion therefore finish together even when frames are delivered more slowly than the nominal transition. A child that does not publish that geometry — a QuickReplyButton or a ButtonDivider — is projected at its current measured size. When the focused child’s width does not change — an icon-only button, for example — the rail falls back to a time-based curve over the same duration. Movement is clamped monotonically for the length of the move, so a resize observed mid-flight can neither reverse the scroll nor push it past its destination; the rail settles exactly on the projected target and then re-measures once. Rapid left/right traversal abandons the in-flight move and starts a new one rather than queuing animations. The first focus into the rail lands immediately, and losing focus does not move the rail. Handle-driven scrolls follow a different curve: they settle with a spring when you pass animated, and jump otherwise. When the display prefers reduced motion, scroll changes jump instead of animating.
Layout changes. The rail watches its viewport, its inner track, and each child for size changes, and recomputes the position for the last focused child. While a focus-driven move is in flight those observations only refresh the cached measurements — they never retarget the move. Changes the rail cannot see — such as a swap that keeps every measured size identical — need updateScrollPosition().
Fading edges. A gradient appears on the left when content is scrolled past the leading edge, and on the right while content extends beyond the trailing edge. The gradients are decorative overlays and do not intercept input.
Focus navigation. Left and right move focus between the rail’s focusable children and are trapped inside the rail: at either end the event is consumed and focus stays put, so horizontal travel never escapes the lane. The trap only applies while focus is on a rail child, and vertical navigation is untouched, so up and down still leave the rail. Disabled buttons keep their focus stop and stay reachable. Vertical overflow stays visible so a focused child’s scale and glow are not clipped, and so the vertical partial-focus nudge can render.
Focus retention. Once a child has been focused, the rail will not let focus collapse into nothing: if focus leaves a child and lands on the document, on an ancestor of the rail, or on the rail viewport itself rather than on another control, the rail returns it to the child that had it last. Focus that moves to a real control outside the rail is left alone, and that is when onChildFocusChange fires with null.
Sizing. The rail fills the width of its container and derives its height from its children. It applies its own horizontal inset to the inner track, so do not add a horizontal page inset or nest it inside another scroll container.

Accessibility

  • The rail viewport is exposed as a group with a built-in accessible name. The name is component-owned and cannot be overridden from props.
  • The rail is not itself focusable and carries no action semantics.
  • Children own their own roles, names, and disabled state. Label icon-only children yourself, with ariaLabel or aria-label on the child.
  • The rail advertises itself as horizontally scrollable, so children scrolled out of the visible clip remain focus-eligible and directional navigation can still reach them.
  • The fading edges are hidden from assistive technology and never receive pointer input.
  • Dock a page-level rail at the bottom of the display, outside the route’s single vertical scroller, and let it reach both edges.
  • Use ButtonDivider only between genuinely distinct logical sections.
  • Call skipAnimationForNextFocusMovement() before restoring focus programmatically so the restored position settles immediately.
  • Call updateScrollPosition() after a content change the rail cannot observe.
  • Validate first, middle, and last positions, overflow gradients, fast traversal, and focus restoration after an overlay or route change.

Avoid

  • Keep the rail outside VerticalList and ScrollView, and away from list headers, so the page retains one clear scrolling boundary.
  • Use SubNavigationPager for tabs, peer-page filters, and other subnavigation.
  • Pass button-like components as direct children. Arbitrary or wrapped children break the rail’s measurement and spacing contract.
  • Let the rail reach the horizontal edges and keep vertical overflow visible so its focus treatment has room to render.
  • Use the rail’s APIs for movement instead of setting scroll offsets or animating its transform.
  • Choose either anchorIndex or centerFocusedView; the modes are not supported together.