Motion
Short, functional transitions. Attention follows the alert component’s lifecycle; measured values update immediately.
On this page
- Anchor drag38 m outside the swing circle.21:02 UTC
- Bilge pump running6 min without stopping.21:03 UTC
- Shallow water2.1 m under the keel.20:58 UTC
Durations
Three durations cover every transition. They are short so that the interface keeps up with a hand on a tablet in a seaway: a state change finishes before the next touch.
| Token | Value | Preview |
|---|---|---|
--motion-fast-duration | 120ms | |
--motion-medium-duration | 200ms | |
--motion-slow-duration | 320ms | |
--motion-standard-easing | cubic-bezier(0.2, 0, 0, 1) | — |
--motion-enter-easing | cubic-bezier(0, 0, 0, 1) | — |
--motion-exit-easing | cubic-bezier(0.3, 0, 1, 1) | — |
--motion-rise-distance | 4px | |
--motion-scale-from | 0.96 | — |
The previews run six times slower than the token, so the difference between the three is visible.
| Duration | Use |
|---|---|
--motion-fast-duration | Hover and press feedback, a chevron opening a group, a change of value, the exit of what enters on medium |
--motion-medium-duration | A dialog, menu, popover, tooltip, toast or small element entering, a toast leaving, an item or indicator sliding to its new place |
--motion-slow-duration | A value's highlight, a cleared alert fading back, a progress bar filling |
An exit takes a shorter duration than the entrance it undoes: the crew asked for the thing to go, so it goes quickly. A toast is the exception: it slides out past the screen's edge over the medium duration. No transition lasts longer than the slow duration; a saved chip stays a while and then fades over the slow duration. The repeating motions are the alarm flash and the progress indicators (the spinner, a skeleton's shimmer, a saving chip's spinner), and the progress indicators stop under reduced motion.
Hover, press, focus and selection feedback may change a control's color, background, border and shadow over the fast duration on the standard curve. It moves nothing and lays nothing out again, so it is the one change of something other than position, size and opacity that transitions. Every transition and animation takes its duration and its curve from the motion tokens; a turning spinner's curve is linear.
The shared flash clock
Unacknowledged alerts flash on one clock, in one of three tempos (--bk-flash-*): fast (400 ms on, 400 ms
off), slow (1200 ms on, 400 ms off) and very slow (2800 ms on, 400 ms off). The on phase carries the tempo,
so a flashing alert stays coloured for most of its cycle; every tempo goes off for the same 400 ms, and the
cycles nest, so all tempos light up together at the start of the slowest one. Every flashing element anchors
the same animation to the document timeline, so alerts in different places pulse in phase. Reduced motion does
not remove the flash: it is the signal that someone has to act.
An unacknowledged alarm or emergency flashes on the fast tempo. The slow and very slow tempos belong to the vendored instruments: OpenBridge's alert frame flashes a rectified alarm very slowly, and draws a warning steady.
Easing
Three curves, one for each direction of travel. Something moving within the screen uses
--motion-standard-easing, which starts quickly and settles slowly. Something arriving uses
--motion-enter-easing and decelerates into place; something leaving uses --motion-exit-easing and
accelerates away. Components that animate from JavaScript read the same values from @bosunkit/tokens/motion.
| Curve | CSS token | Control points |
|---|---|---|
| Standard | --motion-standard-easing | 0.2, 0, 0, 1 |
| Enter | --motion-enter-easing | 0, 0, 0, 1 |
| Exit | --motion-exit-easing | 0.3, 0, 1, 1 |
The motion prop
Components that expose motion use a shared type and vocabulary. Check the component’s props table: this is not
a universal prop on every kit component. CSS-only transitions and instrument blink states have their own contract:
| Value | What it does |
|---|---|
true | The component's own motion |
false | No motion |
"fade", "rise" or "scale" | Enters and leaves with that preset |
{ enter, exit, change, attention } | Each kind named on its own; a kind left out does not move |
enterplays when the element mounts, over200 mson the enter curve.exitplays when the element is removed inside aMotionPresencefrom@bosunkit/components/motion, over120 mson the exit curve. The element is inert from the start of its exit: it takes no press or focus and leaves the accessibility tree, so a second tap on a leaving row cannot act on the row that takes its place.changeplays when the element's value changes:"pop"grows it briefly,"fade"dips its opacity,"highlight"tints its background. The new value is on screen from the first frame of the change. A measured value takes"highlight"only. A pop and a highlight play at most once every2 sper element, so a count that climbs quickly does not keep moving.attentiontakes an alert'spriorityand whether it isacknowledged, and flashes the element on the fast tempo while an alarm or an emergency is unacknowledged; no other priority flashes. The caller owns the acknowledged state, and the flash is never the only signal: the word and the priority color stay. It belongs toMotionElement: a component'smotionprop drops it, since only an alert component flashes, and the badge and the alert card set their own flash.
The following components have motion defaults. motion={false} disables optional motion where the prop is
provided; it is not a way to suppress a separate alert instrument’s lifecycle:
| Component | Default |
|---|---|
| Dialog, select list, combo box list, collapsible content, tooltip | Open and close (in their stylesheets) |
| Popover, menu, context menu, action menu, toast | Open and close (in their stylesheets) |
| Card grid with alert cards and section dividers | Enter, exit and reorder |
| Alert zone | Its alert rises in and fades out; an unacknowledged alarm or emergency flashes on the alarm clock, with or without motion |
| AlertList with AlertListRow | Immediate ordering; no list animation |
| Tabs | The indicator slides between tabs |
| Badge | Pops on a change; a red badge flashes while the caller marks it unacknowledged, with or without motion |
| Segmented radio group | The mark slides between options |
| Chess board | A move slides |
| Button, due chip | A changed label fades in |
| Chat bubble, skeleton, form field error, setting chips, connection notices, reading, instrument pill | Enter and exit |
| Empty state, event timeline entry, copy button glyph | Enter and exit |
A default entrance or exit plays where the caller adds and removes the element: inside a MotionPresence, or a
component's own list (the form field's error, the setting chips' entries, the card grid). Nothing animates in on a
page's first render. A reading's or a pill's highlight stays opt-in, since a tint on every update of a fast
instrument would flicker. The tooltip has no prop.
fadeGPS 3DriseGPS 3DscaleGPS 3DCode
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { MotionPresence, type MotionPreset } from "@bosunkit/components/motion";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The three presets on one badge each: fade changes opacity only, rise moves up a few pixels as it fades in, " +
"scale grows from slightly smaller. Each enters over the medium duration and leaves over the fast one.";
const PRESETS: readonly MotionPreset[] = ["fade", "rise", "scale"];
export default function Presets(): ReactElement {
const [shown, setShown]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: "var(--space-md)" }}>
<Button
size="sm"
onClick={(): void => {
setShown((current: boolean): boolean => !current);
}}
>
{shown ? "Hide the badges" : "Show the badges"}
</Button>
<div style={{ display: "flex", gap: "var(--space-md)", minHeight: 24 }}>
{PRESETS.map(
(preset: MotionPreset): ReactElement => (
<div key={preset} style={{ display: "flex", flexDirection: "column", gap: 6, width: 96 }}>
<code style={{ fontSize: 11, color: "var(--text-dim)" }}>{preset}</code>
<MotionPresence>
{shown && (
<Badge key={preset} variant="green" motion={preset}>
GPS 3D
</Badge>
)}
</MotionPresence>
</div>
),
)}
</div>
</div>
);
}
Gallery
Each kind of motion on its own, played by a button, with the tokens it runs on. The kit reads reduced motion from the operating system only, so a page cannot switch it for one example: turn on reduced motion in the system settings to see each one's reduced form. Entrances and exits then only fade, a pop and a highlight do not play, a reorder and the tabs indicator move at once, and the alarm flash keeps flashing.
Enter and exit
enter 200 ms --motion-enter-easing · exit 120 ms --motion-exit-easingCode
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { MotionPresence } from "@bosunkit/components/motion";
import { MOTION_DURATIONS } from "@bosunkit/tokens/motion";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Enter and exit: a badge the caller adds and removes inside a MotionPresence fades in over the medium duration " +
"on the enter curve, and out over the fast duration on the exit curve.";
export default function EnterExit(): ReactElement {
const [shown, setShown]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-md)" }}>
<Button
size="sm"
onClick={(): void => {
setShown((current: boolean): boolean => !current);
}}
>
{shown ? "Play the exit" : "Play the entrance"}
</Button>
<span style={{ minWidth: 80 }}>
<MotionPresence initial={false}>
{shown && (
<Badge key="fix" variant="green">
GPS 3D
</Badge>
)}
</MotionPresence>
</span>
<code style={{ fontSize: 11, color: "var(--text-dim)" }}>
{`enter ${String(MOTION_DURATIONS.medium * 1000)} ms --motion-enter-easing · exit ${String(
MOTION_DURATIONS.fast * 1000,
)} ms --motion-exit-easing`}
</code>
</div>
);
}
Change: pop
120 ms --motion-standard-easingCode
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { MOTION_DURATIONS } from "@bosunkit/tokens/motion";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Change, pop: a badge's count grows briefly and settles when it changes, over the fast duration on the standard " +
"curve. The new count is there from the first frame.";
export default function ChangePop(): ReactElement {
const [count, setCount]: [number, Dispatch<SetStateAction<number>>] = useState<number>(3);
return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-md)" }}>
<Button
size="sm"
onClick={(): void => {
setCount((current: number): number => current + 1);
}}
>
Another target
</Button>
<Badge variant="blue">{`${String(count)} AIS targets`}</Badge>
<code style={{ fontSize: 11, color: "var(--text-dim)" }}>
{`${String(MOTION_DURATIONS.fast * 1000)} ms --motion-standard-easing`}
</code>
</div>
);
}
Change: fade
120 ms --motion-standard-easingCode
import { Button } from "@bosunkit/components/button";
import { MOTION_DURATIONS } from "@bosunkit/tokens/motion";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Change, fade: a button's new label fades in when it changes, over the fast duration on the standard curve. The " +
"button keeps its focus, and the fade stays under reduced motion, as it changes only opacity.";
export default function ChangeFade(): ReactElement {
const [acknowledged, setAcknowledged]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-md)" }}>
<Button
variant="primary"
onClick={(): void => {
setAcknowledged((current: boolean): boolean => !current);
}}
>
{acknowledged ? "Acknowledged" : "Acknowledge"}
</Button>
<code style={{ fontSize: 11, color: "var(--text-dim)" }}>
{`${String(MOTION_DURATIONS.fast * 1000)} ms --motion-standard-easing`}
</code>
</div>
);
}
Highlight
320 ms --motion-exit-easing · at most every 2 sCode
import { Button } from "@bosunkit/components/button";
import { Reading } from "@bosunkit/components/reading";
import { MOTION_DURATIONS, MOTION_HIGHLIGHT_INTERVAL } from "@bosunkit/tokens/motion";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Highlight, opt-in: a reading tints its background when a new value arrives, over the slow duration on the exit " +
"curve, at most once per interval. Press quickly and only the first press in each interval tints.";
const DEPTHS: readonly number[] = [8.4, 8.1, 7.6, 7.2, 7.5];
export default function Highlight(): ReactElement {
const [sample, setSample]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-md)" }}>
<Button
size="sm"
onClick={(): void => {
setSample((current: number): number => (current + 1) % DEPTHS.length);
}}
>
Next depth reading
</Button>
<Reading
value={DEPTHS[sample]}
unit="m"
label="Depth"
source="DST810"
fractionDigits={1}
motion={{ change: "highlight" }}
/>
<code style={{ fontSize: 11, color: "var(--text-dim)" }}>
{`${String(MOTION_DURATIONS.slow * 1000)} ms --motion-exit-easing · at most every ${String(
MOTION_HIGHLIGHT_INTERVAL,
)} s`}
</code>
</div>
);
}
Attention
--bk-flash-fast-on · --bk-flash-fast-offCode
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Attention: an unacknowledged alarm's dot flashes on the shared alert clock until it is acknowledged, the only loop " +
"on screen. It keeps flashing under reduced motion.";
export default function Attention(): ReactElement {
const [acknowledged, setAcknowledged]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-md)" }}>
<Button
size="sm"
onClick={(): void => {
setAcknowledged((current: boolean): boolean => !current);
}}
>
{acknowledged ? "Raise again" : "Acknowledge"}
</Button>
<Badge variant="red" unacknowledged={!acknowledged}>
{acknowledged ? "Bilge alarm, acknowledged" : "Bilge alarm"}
</Badge>
<code style={{ fontSize: 11, color: "var(--text-dim)" }}>--bk-flash-fast-on · --bk-flash-fast-off</code>
</div>
);
}
Reorder
200 ms --motion-standard-easingCode
import { AlertCard } from "@bosunkit/components/alert-card";
import { Button } from "@bosunkit/components/button";
import { AlertCardGrid } from "@bosunkit/components/alert-card-grid";
import { MOTION_DURATIONS } from "@bosunkit/tokens/motion";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Reorder: an alert list slides its cards to a new order over the medium duration on the standard curve. While a " +
"card has focus or the pointer is pressed on it, the new order applies at once.";
interface Item {
readonly id: string;
readonly title: string;
}
const ITEMS: readonly Item[] = [
{ id: "wind", title: "Wind above reef threshold" },
{ id: "depth", title: "Shallow water" },
{ id: "battery", title: "House battery low" },
];
export default function Reorder(): ReactElement {
const [order, setOrder]: [readonly Item[], Dispatch<SetStateAction<readonly Item[]>>] =
useState<readonly Item[]>(ITEMS);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-md)" }}>
<Button
size="sm"
onClick={(): void => {
setOrder((current: readonly Item[]): Item[] => [...current.slice(1), ...current.slice(0, 1)]);
}}
>
Move the top card down
</Button>
<code style={{ fontSize: 11, color: "var(--text-dim)" }}>
{`${String(MOTION_DURATIONS.medium * 1000)} ms --motion-standard-easing`}
</code>
</div>
<AlertCardGrid>
{order.map(
(item: Item): ReactElement => (
<AlertCard key={item.id} className="alarm--full" priority="warning" title={item.title} />
),
)}
</AlertCardGrid>
</div>
);
}
Tabs indicator
200 ms --motion-standard-easingCode
import { Tabs, type TabsOption } from "@bosunkit/components/tabs";
import { MOTION_DURATIONS } from "@bosunkit/tokens/motion";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Tabs indicator: the underline slides to the tab the crew selects, over the medium duration on the standard curve.";
const TABS: readonly TabsOption[] = [
{ id: "passage", label: "Passage" },
{ id: "weather", label: "Weather" },
{ id: "tides", label: "Tides" },
];
export default function TabsIndicator(): ReactElement {
const [active, setActive]: [string, Dispatch<SetStateAction<string>>] = useState<string>("passage");
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
<Tabs tabs={TABS} value={active} onValueChange={setActive} />
<code style={{ fontSize: 11, color: "var(--text-dim)" }}>
{`${String(MOTION_DURATIONS.medium * 1000)} ms --motion-standard-easing`}
</code>
</div>
);
}
Overlay open and close
open 200 ms --motion-enter-easing · close 120 ms --motion-exit-easingCode
import { Button } from "@bosunkit/components/button";
import { TaskDialog } from "@bosunkit/components/task-dialog";
import { MOTION_DURATIONS } from "@bosunkit/tokens/motion";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Overlay open and close: a dialog scales in over a fading overlay, in its stylesheet on the same tokens, and leaves " +
"faster than it came. Its title and body are there from the first frame.";
export default function Overlay(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
function close(): void {
setOpen(false);
}
return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-md)" }}>
<Button
size="sm"
onClick={(): void => {
setOpen(true);
}}
>
Open the dialog
</Button>
<code style={{ fontSize: 11, color: "var(--text-dim)" }}>
{`open ${String(MOTION_DURATIONS.medium * 1000)} ms --motion-enter-easing · close ${String(
MOTION_DURATIONS.fast * 1000,
)} ms --motion-exit-easing`}
</code>
<TaskDialog
open={open}
onOpenChange={(open): void => { if (!open) (close)(); }}
title="Engine hours logged"
actions={
<Button variant="primary" onClick={close}>
Close
</Button>
}
>
1,842 engine hours are in the log. The next oil change is due at 1,900.
</TaskDialog>
</div>
);
}
Motion on and off
motion on (default)3 AIS targetsDue tomorrowmotion={false}3 AIS targetsDue tomorrowCode
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { DueChip } from "@bosunkit/components/due-chip";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Motion on and motion={false} side by side: one press changes both columns, and only the left one moves.";
const COLUMN: CSSProperties = { display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 10, width: 220 };
export default function OnOff(): ReactElement {
const [step, setStep]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
const overdue: boolean = step % 2 === 1;
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}>
<Button
size="sm"
onClick={(): void => {
setStep((current: number): number => current + 1);
}}
>
Change everything
</Button>
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-lg)" }}>
<div style={COLUMN}>
<code style={{ fontSize: 11, color: "var(--text-dim)" }}>motion on (default)</code>
<Badge variant="blue">{`${String(step + 3)} AIS targets`}</Badge>
<DueChip priority={overdue ? "warning" : "caution"} label={overdue ? "Overdue" : "Due tomorrow"} />
<Button size="sm">{overdue ? "Acknowledged" : "Acknowledge"}</Button>
</div>
<div style={COLUMN}>
<code style={{ fontSize: 11, color: "var(--text-dim)" }}>motion={"{false}"}</code>
<Badge variant="blue" motion={false}>{`${String(step + 3)} AIS targets`}</Badge>
<DueChip
priority={overdue ? "warning" : "caution"}
label={overdue ? "Overdue" : "Due tomorrow"}
motion={false}
/>
<Button size="sm" motion={false}>
{overdue ? "Acknowledged" : "Acknowledge"}
</Button>
</div>
</div>
</div>
);
}
Alerts and live values
AlertCardGrid can animate alert cards entering, leaving and changing order. The dedicated AlertList applies its
order immediately and has no motion prop. For compositions that animate, these limits apply:
- An alert arrives at once. Its text is in the page and announced by its live region the moment it arrives.
The entrance changes only opacity and transform, lasts at most
200 ms, and never delays the text or the announcement. Under reduced motion it only fades. - Removal follows the alert policy. When the caller removes an alert, its exit is fast and inert. Acknowledgment alone does not mean the cause has cleared or that the alert should leave the list.
- The list moves to its new order, unless the crew is using it. Items slide to their new places. While an item has pointer or keyboard focus, or the pointer is pressed on one, the new order applies at once, so nothing moves under a finger. Under reduced motion the new order always applies at once.
- A change of priority and an acknowledgment change the card at once. Only an unacknowledged alarm or emergency flashes.
- ACK is pending until bosund answers. The alert keeps flashing until the acknowledgment is confirmed, and a failed acknowledgment offers ACK again.
A measured value never rolls or tweens: it jumps to the latest measurement. A reading or an instrument pill may
highlight its update when the caller asks, with motion={{ change: "highlight" }}: its background tints for
320 ms, at most once every 2 s per
value, so a fast instrument does not flicker. Under reduced motion it never highlights.
The alarm flash
An unacknowledged alarm or emergency flashes on the fast tempo: 400 ms on, 400 ms off. Every flashing element on screen runs on the same clock, anchored to the start of the page, so two alarms never flash out of step. The flash stops when the acknowledgment is confirmed; the alert then stays red and steady until it clears.
- Anchor drag38 m outside the swing circle.21:02 UTC
- Bilge pump running6 min without stopping.21:03 UTC
- Shallow water2.1 m under the keel.20:58 UTC
A warning and a caution are steady, acknowledged or not: AlertListRow gives its AlertIcon steady for every
priority that does not flash. The timing tokens are --bk-flash-fast-on and --bk-flash-fast-off for the alarm,
and the slow and very slow pairs the vendored instruments use. The rows on this page are the kit's
alert list row on that clock. These implementation timings are not a certification
claim.
Using motion
GPS 3D
8.4m
Reduced motion
When the device asks for reduced motion, nothing on screen moves or changes size. An element that would rise or scale only fades, over the same duration, so the crew sees that something arrived; a change's pop and a value's highlight do not play, and a list's new order applies at once. The flash of an unacknowledged alarm continues: reduced motion removes motion that is not essential, and the flash is how an alarm says that someone has to act (MSC.302 §7.3.3). Acknowledging the alarm stops the flash, as it does without the preference.
A page can choose for the device with data-prefers-reduced-motion on its root element: reduce applies the reduced
motion whatever the device asks, no-preference the full motion, and with neither the device decides. Every kit
stylesheet and usePrefersReducedMotion read it, so an application's motion setting, or this site's motion switch,
reaches every component.
Documents, diagrams and reduced motion
An exported chart or architecture diagram must communicate without animation. Make the result and the final state readable in a static frame. A replay in a report needs an explicit replay label and a fixed source period; an animated specimen is not evidence of a live connection.
Check reduced motion on the actual component used. Kit instrument alert clocks preserve essential alert-state flashing, while optional movement and scaling follow reduced-motion preferences. Application adapters can have different behavior; a foundation example does not establish that every consumer has adopted the kit contract.
The public site
The public site may give its own pages entrance motion, on the same tokens and under the same reduced-motion rule. The app's rules hold for every screen the crew uses.