Skip to content

Trace

Light real UI up like a circuit: a thin signal runs its layout and each part appears as the signal reaches it.

Use it for an entrance that shows how a piece of UI is wired: a hero demo, a launch moment, a dashboard coming online. A thin signal comes in from beside the frame and runs both ways round its border. Then it spreads down the layout: along each box's edge, across the gutter above what the box holds and down a short square connector into each region, round that region's border, and on to every part, all branches at once. Each part glows briefly on its edge as the signal reaches it, then appears. Keep it to one moment on a page: it asks to be watched.

Installation

npm i @youj/motion

import { TraceEntrance } from '@youj/motion'

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Usage

Wrap the UI in TraceEntrance. It lights up its first child and plays on mount.

import { TraceEntrance } from '@youj/motion'

export function Hero() {
  return (
    <TraceEntrance>
      <Dashboard />
    </TraceEntrance>
  )
}

Change play to play it again (a counter works), or set it to false to show the UI as it is. onFinish is called when the run ends.

For an element you don't render with React, call the engine. It returns a cancel() and a finished promise, and an AbortSignal in signal cancels it too. To drive it from a clock of your own (a video rendered frame by frame, a scrubber), pass paused: true and call seek(t) with the time in ms from its start: it shows that moment and holds it. It only goes forwards, and a time at or past its end ends it.

import { trace } from '@youj/motion'

const run = trace(document.querySelector('#overview')!, { speed: 1.5 })
await run.finished

Color

The signal is the theme's foreground, so it reads on every theme. accent draws it in the theme's accent instead. The path it has run stays etched faintly behind it until the run settles, then fades.

Pacing

The run goes through three stages: enter (the signal comes in and runs round the frame, and the frame fills in behind it), spread (it runs down the layout and lights every part) and settle (the etched path fades and anything floating fades in). The signal runs at one speed through the spread, so near parts light first and the farthest part lights last.

The length grows with the demo: about 2 seconds for a card and about 3.5 for a small screen. Three options tune it:

  • duration fixes the whole run, in milliseconds.
  • speed scales it: 2 plays twice as fast. It applies on top of duration and the provider's speed.
  • phases shares the run out between the three stages. The values are weights, so they are normalized and never change the total; a stage left out keeps its default ({ enter: 14, spread: 74, settle: 12 }), and 0 skips it.
// a quick run that is nearly all spread
<TraceEntrance duration={1400} phases={{ enter: 8, spread: 84, settle: 8 }}>
  <PricingCard />
</TraceEntrance>

What it reads

The layout comes from the real DOM, read once before the first frame: roles, tags and data-slot first, then geometry. The main regions and the panels too big to be one part are the branches, each inside the smallest one that holds it; their borders are traced on their own edges, rounded corners included, and a panel fills in as its border is traced. The parts are the leaves: a field, a button, a stat card, a list row, a paragraph, or a part with nothing inside it (a chart, an image). What's inside a part comes with it. Open popovers simply fade in at the end.

Reference

Propsplay, duration, speed, phases, accent, onFinish

Props

TraceEntrance props
PropType
childrenRequiredReactNode

The UI to light up: its first element is the frame.

–
playboolean | number | string

Plays on mount and again whenever this changes; false shows the UI as it is.

default true

true
onFinish() => void

Called when a run ends, also early when focus moves into the UI (not when unmounting or a new play cancels it).

–
speednumber

Multiplies the pace: 2 plays twice as fast.

default 1

1
accentboolean

The signal in the theme's accent instead of its foreground.

default false

false
durationnumber

The whole run in ms, before speed. Default: from the demo's size and part count, about 2 s for a card, about 3.5 s for a small screen.

–
phasesTracePhases

Each stage's share of the run, as weights (normalized, so they never change the total; a stage left out keeps its default, and 0 skips it).

default `{ enter: 14, spread: 74, settle: 12 }`

`{ enter: 14, spread: 74, settle: 12 }`
Other props pass through: extends HTMLAttributes<HTMLDivElement>.

trace

function trace(node: HTMLElement, options?: TraceOptions): { cancel(): void; finished: Promise<void>; seek(t: number): void }
OptionTypeDefaultDescription
durationnumberfrom the demo's sizeThe whole run in ms, before speed.
speednumber1Multiplies the pace.
phasesTracePhases{ enter: 14, spread: 74, settle: 12 }Each stage's share of the run, as weights.
accentbooleanfalseThe signal in the theme's accent instead of its foreground.
signalAbortSignalAborting cancels the run.
reducedbooleanthe provider, else the OSSkip the entrance.
personalityMotionPersonalitythe theme'sThe motion personality its timing and curves come from.
pausedbooleanfalseHold the run at its start until seek(t) moves it, for a clock of your own.
MotionEnter, spread, settle · the glow · reduced motion

Motion

Motion tokens
MomentTokens
Enterin from beside the frame, then both ways round its border; the frame fills in
ease.linear

cubic-bezier(0, 0, 1, 1) · 14% of the run

cubic-bezier(0, 0, 1, 1)14% of the run
Spreaddown the layout at one speed, every branch at once; panels fill in as their borders are traced
ease.linear

cubic-bezier(0, 0, 1, 1) · 74%

cubic-bezier(0, 0, 1, 1)74%
Settlethe etched path fades; open popovers fade in
ease.exit

cubic-bezier(0.4, 0, 1, 1) · 12%

cubic-bezier(0.4, 0, 1, 1)12%
Glowa part’s edge lights as the signal reaches it, then fades
duration.slowease.linear

340ms · cubic-bezier(0, 0, 1, 1)

340mscubic-bezier(0, 0, 1, 1)
Part inopacity, just after its glow
duration.baseease.standard

220ms · cubic-bezier(0.2, 0, 0, 1)

220mscubic-bezier(0.2, 0, 0, 1)

Reduced motion

No signal: the UI is just there, and nothing is added to the page.

The demo is never restyled. While a part waits it is held by its inline opacity (no animation, so no compositor layer), and it appears by a Web Animation that replaces the hold when the signal reaches it. The signal's heads are small elements moved by transform along their routes, made just before they set off; the etched path is drawn on one canvas, a frame adding only the few pixels run since the last one. When the run ends, or cancel() is called, the animations are cancelled, the holds are put back exactly and the overlay is removed, so the page is exactly as it was. Focus or a press in the demo ends the run at once.

Reduced motion

The UI shows as it is, with no overlay and no animation. trace() returns a run that has already finished.

AccessibilityARIA, focus, pointer

Accessibility

  • The signal and the etched path are aria-hidden and never take the pointer. The demo stays in the accessibility tree the whole time, so a screen reader reads the real UI.
  • Tabbing into the demo, or pressing on it, ends the run at once, so nobody uses a part that hasn't appeared yet.
  • Nothing flashes: the signal is a 1 px line with a small head, and each glow is faint and brief.