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.ButtonRail when the action count, label length, or available width could cause overflow, and ButtonGroup when a small set always fits.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);
}
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>;
ButtonRailProps.| Prop | Type | Default | Description |
|---|---|---|---|
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.ButtonRail forwards a ButtonRailHandle.| Method | Description |
|---|---|
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();
}
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.centerFocusedView deliberately lifts that clamp, because centering an end child requires moving content beyond the edge.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.updateScrollPosition().onChildFocusChange fires with null.ariaLabel or aria-label on the child.ButtonDivider only between genuinely distinct logical sections.skipAnimationForNextFocusMovement() before restoring focus programmatically so the restored position settles immediately.updateScrollPosition() after a content change the rail cannot observe.VerticalList and ScrollView, and away from list headers, so the page retains one clear scrolling boundary.anchorIndex or centerFocusedView; the modes are not supported together.