exotic · motion
colorway
theme
motion
move your cursor through it

meridian · motion · proposed 2026-07-26

The motion kit

That is one field of 2,400 dots, and it is never rebuilt between acts. It deforms: the logo's six strokes scatter onto the spinning globe, the globe collapses into your balance, the digits fall onto a price line. Every act is a real exotic artifact drawn from the real geometry, and each dot keeps its place on the meridian the whole way through, so you can follow one of them from the mark to the market. Move your cursor into it.

The stage exists to make one argument. We already own two pieces of motion nobody else has: the dot that breathes a ring on the landing globe, and the six strokes travelling on one sine in the mark. Everything below descends from one of those two, not as an homage but as the same functions re-scaled. A loading state should be recognisable as ours before you read a word of it.

Builders in js/motion-kit.js (window.MOTION_KIT), styles in css/motion-kit.css. Every specimen below is live and running the real code. Flip the theme, the colorway, or Reduced and watch each one answer honestly.

The dot

In letter/globe.js, a tradable name announces itself by breathing a ring out of its dot on the lattice, and neighbours are offset so a handful are ever alive at once.

pulse(k) → r 3 + 9k px · α (1−k)·0.5 over 2.2s phase(i) → (t + i·7.13s) mod 32s ≈ 4 alive

The wave

In js/exotic-brand.js, the mark is six brush-strokes on one travelling sine, each lagging the last. Its crash mode rolls a swell across the box once and lets it recede.

wave(u) → A sin(uk + φ) A .07S · λ .95S crest(p) → 1 + 1.9·sin(πp)·e^−(u−s)²/2σ²

0 · Hero graphics — one primitive, repeated

Six posters in the Swiss line language: a single primitive per graphic, repeated with a parametric offset, drawn as strokes and never as fill. That last constraint isn't borrowed from a moodboard, it's the meridian law we already wrote down, which is why a line poster is the one poster format this brand can actually make. Each one carries a meaning rather than a mood, and each is assigned a page. Move your cursor through them — every one answers.

The rule that makes these ownable

I · Pulse — "this is alive"

The dot atom at its own scale. One dot, one ring, 2.2 seconds. Everything that needs to say this number is live or that landed is this, and nothing else needs to be invented.

Pingpulse · a live indicator MOTION_KIT.ping(host, {tone})

An ink dot with one ring of brand light breathing out of it. Set beside a number, it means the number is moving. It is the globe's tick-dot at 8px, on the same radius ramp and the same alpha falloff. The dot stays neutral by default and the ring carries the ramp, which is the meridian law and also what keeps it unambiguous under Solar, where every ramp stop is warm.

SK Hynix000660 ₩ 214,500
market open margin at 78% brand light quiet · in-button
lineage
globe.js's tick-dot: r 3→12px, α (1−k)·0.5, 2.2s.
tones
Ink dot, ramp ring. up/down/warn colour both; ramp fills the dot.
use for
A live price · market open · connection state.
not for
Inside a CTA — that's .pending's tick-dot, the same atom minus the ring.
reduced
Ring stops, dot keeps a halo. A state, not decoration.
Rippleripple · origin-aware press feedback MOTION_KIT.rippleOn(node)

The same ring, fired once, from the exact point you pressed. Not a material-design flood-fill: a single hairline ring that expands and is gone in 620ms. Press these.

Delight, not information. It confirms the tap landed where you thought it did, which is the one thing a touch target can't otherwise prove.

lineage
The ping's ring, one shot, from the pointer.
use for
Primary CTAs · trade side buttons · ⌘K enter.
not for
Anything pressed dozens of times a session.
reduced
Gone. The .97 press already confirms the tap.

II · Lattice — "this is loading" / "this is moving"

The globe's dot grid, flattened. A shimmer sweeping across a grey bar says a machine is scanning this. A field of dots where a few quietly breathe says this place is populated and the contents are on their way, which happens to be both true and already ours.

Fieldskeleton · a placeholder that is alive MOTION_KIT.field(host).resolve()

The lattice stands in for content that hasn't arrived, in the shape it will arrive in. When it does, the dots clear row by row, 77ms apart (the mark's own per-stroke lag) and the real thing fades up underneath. No sheen sweeps across it at any point.

Holdings
SK Hynix2× long+$4,182
Tokyo Electron1.5× long+$1,905
Robotics thesis9 holdings−$611

This is the proposed replacement for .kit-skel's shimmer bar. Same job, our vocabulary, and it degrades to a plain still field rather than a stuck gradient.

lineage
globe.js's lattice + the i·7.13 stagger. mask() gives it the shape it stands in for.
use for
Lobby holdings · Asset metrics · Explore tiles.
not for
Waits under 300ms, or with no known shape (use Think).
reduced
Still field; resolve() cuts instead of cascading.
Field · idleambient / idle animation MOTION_KIT.field(host, {mode:'idle'})

The same field with nothing to resolve into: an empty surface that is quietly inhabited instead of quietly broken. The dots walk the meridian by row, so it carries the brand without a single filled pixel of it.

No activity in this filter yet.
Trades and transfers land here.
lineage
The same field. Only alpha and pitch change.
use for
Empty filters · no-results · a market not yet open.
not for
Behind anything read at length.
reduced
Still field. Only the breathing stops.
Traildot trail · work in transit MOTION_KIT.trail(host).progress(p)

Lattice dots laid along a route instead of a grid, with a packet of three walking it and the leading dot carrying the ping. The path behind stays lit, so how far is legible without a percentage. Determinate when the wait has a known end, looping when it doesn't.

Deposit · $25,000 in flight
your bankexotic
lineage
The lattice along a path; the route is one wavelength of the mark's sine.
use for
A deposit in flight · a withdrawal settling · order routing.
not for
Progress with no spatial meaning.
reduced
Packet stops; the lit path and arrival state remain.
Tallystepped animation · countable progress MOTION_KIT.tally(host, {total}).step()

When the steps are discrete and few, show the steps. Dots you can actually count, walking the meridian left to right, each landing one lag after the last. The frontier dot wears the ping: the one thing currently happening is the one thing that beats.

0 of 6 confirmations

Six dots is the ceiling. Past that you are drawing a bar, and a bar should say a number instead.

lineage
The lattice made countable; the frontier dot is a full Ping.
use for
Deposit queue · multi-step withdrawal · onboarding.
not for
More than six steps, or an unknown total.
reduced
All state survives; transitions cut. It's a role="progressbar".

III · Wave — "we're working" / "done"

The mark atom. When exotic is thinking, the thing that moves should be exotic rather than a borrowed rotating arc. And when something completes, a wave should reach shore and recede, because that is the shape the brand already makes.

Thinkindeterminate progress · the logo, thinking MOTION_KIT.think(host, {size})

The strongest single element here, and the least invented: this is mountMark at 20px in its ordinary wave mode. The spinner is the logo, with no new asset and no new vocabulary, and the six strokes travel exactly as they do at hero size.

Fetching the book…

16 / 24 / 40px. Below about 14px the strokes fuse and it reads as a smudge, so use a quiet Ping there instead.

lineage
EXOTIC.mountMark({motion:'wave'}), drawn at 6× and scaled down.
use for
Surface waits with no known shape: Scout · ⌘K · news.
not for
Inside a CTA. And never beside a Field — one of them is lying.
reduced
Mounts still. The live region does the telling.
Ruleidle animation → the crest MOTION_KIT.rule(host).crest()

One element, two behaviours, because they are one function upstream: the mark's ampAt returns a steady amplitude in wave mode and a rolling gaussian swell in crash mode. So a divider can be quietly alive while its section loads, and then the swell crosses once, the line settles flat, and that settling is the confirmation. Press crest.

2.4×
effective leverage
recalculating
lineage
ampAt from mountMark, both modes. Crash retimed 3600ms → 1400ms.
use for
Breathing: above a table still filling. Crest: filled · landed · copied.
not for
Errors. A wave arriving reads as good news.
reduced
Flat line; crest() settles instantly.

IV · Auction — "the clearing beat is in seven seconds"

The one element on this page that could only exist in this product. exotic clears in a batch auction, so a trade waiting for confirmation is not an unknown wait. It is a known interval, and spinning an indeterminate arc at someone who could be told the number is a small lie.

Arcdeterminate progress · the clearing countdown MOTION_KIT.arc(host, {seconds, total})

The lattice bent into a ring: one dot per beat, lighting in reading order along the meridian, with the seconds in the middle in tabular mono. The frontier dot pings. When the clock lands, the ring swells outward once (the crest, wrapped around a circle) and the face resolves.

Your order joins the next auction. Everyone in it trades at the same price.
Buy 120 · SK Hynix · 2× long
lineage
Lattice + ping + crest in one — the only specimen using both atoms.
use for
The trade ticket · next-auction countdown · settling rows.
not for
Any wait whose length you don't actually know.
reduced
Dots, numeral and cleared state survive; only the swell goes.

V · Delight — the ones with no job

Two pieces that inform nobody of anything. They exist because the mark's own code left the door open for them, and because software people love has details nobody reports.

Swimphase-synced companion MOTION_KIT.swim(host, {word})

mountMark exposes getPhase(), and its own comment says why: "lets a companion renderer swim in sync." Somebody built that hook and nothing has used it. So the wordmark's underline is not a rule beside the mark, it is the mark's wave at the mark's phase. Hover the mark; the mark speeds up, and so does the underline, because they are one wave.

Nobody will report this in a survey. It is the difference between a logo placed next to a line and a logo that owns the line.

lineage
One sine, two renderers, one phase, via getPhase().
use for
Sidebar wordmark · boot splash · Save.html.
not for
More than one place in a view.
reduced
Both go still, in phase, as drawn.
Washscoped reveal · a card refreshing MOTION_KIT.wash(card).once(swap)

The page sweep in js/wave-sweep.js washes the whole viewport between pages. Bounded to one card, the same sheet is how a single value should refresh: the wave covers it, the number changes underneath, the wave leaves. No spinner and no flash of empty, because the value was never absent, it was behind the light.

Portfolio value
$1,284,910
as of 14:02 · +2.1% today

Cover 420ms (--t-ovl), reveal 700ms (--t-story). The exit is not faster here, because unlike a dismissal this one is showing you something.

lineage
wave-sweep.js's sheet, container-scoped — transient light, not a surface.
use for
Colorway change · range re-quote · portfolio refresh.
not for
First load (use Field) or high-frequency ticks.
reduced
Both cut instantly; the swap happens under cover.

VI · Live — motion you hold the other end of

Everything above is ambient or state-indicating. These four are driven by the reader: two by scroll, two by the finger. The scroll pair are native CSS (animation-timeline: view()) — no library, no scroll listener, no rAF, so the compositor runs them and they stay smooth exactly when the main thread is busiest.

World clockdrag to scrub · interruptible MOTION_LIVE.clock(host)

Drag it sideways. One screen is 24 hours. The daylight band moves, the exchanges open and shut, and on release it springs home to the real hour — you can look at another time, but you can't leave it there. Notice the stretch where nothing is open.

utc release to return to now
lineage
The daylight poster, made draggable, over real exchange hours in UTC.
use for
Home hero · Brand · the "what is a synthetic perp" explainer.
not for
Anywhere a wrong hour would matter. It always returns to now.
reduced
Drag off, hour pinned to now. Every state stays readable.
Scrubscroll-driven reveal MOTION_LIVE.scrub(host, {readout})

The price draws itself as you scroll past, and the readout counts with it. The line is a clip-path wipe on a view() timeline, so it rides the compositor; only the number needs JS, because a number has to be computed. Scroll this one up and down.

₩ 214,500 +0.0%
lineage
EXOTIC_CHARTS.walk + a clip wipe, never a dash animation.
use for
Asset page below the fold · thesis performance · the letter.
not for
A chart you need to read at once. Scrolling is not a range picker.
reduced
Drawn in full, readout at its final value.
Kineticscroll-positional stagger · zero JS MOTION_LIVE.kinetic(el)

Every word takes its own slice of one shared view() timeline, so the stagger is tied to scroll position and not to a clock. Scroll back up and the words un-reveal in order — a time-based stagger can't do that at any price.

The sun is always up somewhere, so a market is always somewhere in its day. We built the venue that never has to close, and priced it against the exchanges that do.

lineage
One view() timeline, each word's range offset by its index.
use for
The letter · a thesis argument · the brand pages.
not for
Anything above the fold, or copy someone has to scan.
reduced
Fully revealed, no blur, no travel.
Odometernumber ticker · changed digits only MOTION_LIVE.odo(host).set(v)

Every money app rolls the whole number. A real odometer doesn't — the ones column spins constantly and the millions column sits still for years. So only the columns that actually changed move, cascading from the right at one LAG each, the direction a carry travels.

portfolio value

The moved column keeps a hair of ramp for a beat. It's the only thing that tells you which digit changed.

lineage
A digit column translated −10% per digit; delay from MOTION_KIT.LAG.
use for
Live P&L · portfolio value · the funding meter.
not for
Values that change every tick — at that rate, no motion.
reduced
Digits swap. The number is information; the spin isn't.

VII · Components — the parts a product is made of

Buttons, charts, scroll rails, an answer arriving. Organised against the principles in devouringdetails, because they name the reasons better than "it looks nice" does. Two rules run through all six: slow where the user is deciding, fast where the system is responding, and a value that changes should move to its new value, never cut to it.

Hold to confirmergonomic interactions MOTION_PARTS.hold(btn, {onConfirm})

Closing every position shouldn't be one click from a fat finger, and it shouldn't be a modal either — a modal moves the decision somewhere else. Holding is the confirmation. Press and hold. Let go early and it snaps back in 200ms.

1.6s linear in, 200ms ease-out back. The press is the user deciding; the release is the system responding.

lineage
A clip-path fill behind the label, not a progress bar beside it.
use for
Close all · cancel all · withdraw everything.
not for
Anything reversible. Effort should match consequence.
reduced
One press confirms; no travelling fill.
Morph surfaceinteraction metaphors MOTION_PARTS.morph(btn).run()

Most products swap a button for a spinner and then for a toast: three objects, and you have to re-find each one. This is one surface that changes width and content in place. The label crossfades through a 2px blur, which is what stops the eye reading two overlapping words instead of one word changing.

The width is measured from the next label before it's shown, so the surface can travel while the words are still blurred out. The two never fight for the same frames.

lineage
Width + background travel; the pending beat is still kit.css's tick-dot.
use for
Place order · deposit · copy a thesis.
not for
Actions that can fail loudly — a failure needs its own block, not this.
reduced
Colours and labels change; the travel doesn't.
Minimapcontained gestures MOTION_PARTS.minimap(host)

A range picker throws you between presets. A minimap keeps the whole series on screen and lets you hold a window over it. Drag the window. The main chart rescales its y-axis to what's inside — without that you're cropping, not zooming, and everyone forgets it.

lineage
The window is the control; grab bars are the affordance, not a tooltip.
use for
Asset chart · thesis performance · Activity's timeline.
not for
Series short enough to show whole. Then there's nothing to map.
reduced
Fully usable — arrow keys move it, nothing animates.
Range morphmotion choreography MOTION_PARTS.range(host)

Every chart on the internet blanks and redraws when you change the range. That throws away the only thing you asked for, which is the relationship between the two views. Interpolating point by point costs nothing. Switch between them.

lineage
Same point count per series, y-normalised, eased 520ms between them.
use for
Every range picker in the product.
not for
Series of different lengths without resampling first.
reduced
Cuts to the new series.
Streaminferring intent MOTION_PARTS.stream(host).play(text)

Text appearing character by character is a typewriter, and a typewriter is a costume. Real generated text arrives in tokens, so it should land in word-sized pieces, each blurring up. The caret blinks only once the stream stops — during output, two things would be claiming to be the live edge.

why did this move?
lineage
Word-sized chunks on an uneven cadence — a flat one reads as fake.
use for
Explainers · ⌘K answers · the news summary.
not for
Text that already exists. Streaming a known string is theatre.
reduced
The whole answer, at once.
Scroll stripresponsive interfaces MOTION_PARTS.strip(host, {items})

A rail whose content is cut off at the edge is a bug that looks like a layout. The fade isn't decoration — it's the only thing telling you there's more, and it appears only on the side that has more. Scroll it sideways.

lineage
A scroll() timeline on the rail, so the fade can't desync.
use for
Movers · theme chips · the range picker on phones.
not for
Rails that always fit. A fade that never changes is noise.
reduced
Both fades stay on. They're information, not motion.

VIII · Inference & time — the layer gets thinner

The five pieces from the review pass, built from Rauno Freiberg’s reference components (Devouring Details, credited per piece). The through-line is his thesis: the interface should already know — from your clipboard, your selection, your history — and a wrong guess must cost one glance.

Time machinethe account, scrubbed backwards MOTION_SENSE.timeMachine(host)

Drag anywhere on the panel. The value rolls through the odometer (only changed digits move), ledger rows arrive and leave as you cross their dates. Unlike the clock it stays where you leave it — history is a place you visit — so a chip carries you back.

lineage
DD time-machine; the value display is this kit’s own odometer.
use for
Activity’s header · the story page.
not for
Anything that places orders while scrubbed.
reduced
Drag still 1:1; “back to now” cuts.
Auction dialthe ring, scrubbable MOTION_SENSE.dial(host)

The Arc said the wait is known. This says so is the history: drag sideways and the ring rotates under your finger — whatever sits at the notch is the clearing you’re reading. Release and it springs home to live, overdamped, no oscillation.

Drag = dx × 0.5, 1:1 while down; release springs on ~150/42 — the reference’s values. Arrow keys step clearings; Esc returns to live.

lineage
DD radial-timeline’s rotation mapping on the Arc’s own ring.
use for
The trade ticket · Activity’s settling rows.
not for
More history than a ring can hold — that’s the time machine.
reduced
Drag steps; release cuts to live.
Clipboard depositinferring intent MOTION_SENSE.deposit(host)

Copy the bank balance, then focus the field. What you copied is offered as a chip — offered, never auto-filled, so a wrong guess costs one glance. Accepting it counts the value in over 240ms; pasted junk (“$2,500.00”) lands formatted.

lineage
DD copy-paste; clipboard read is permission-gated, the copy button is the fallback.
use for
The deposit sheet · withdraw · transfer amounts.
not for
Auto-filling. The chip asks.
reduced
Chip appears/fills instantly.
⌘K inferencethe palette already knows MOTION_SENSE.kInfer(stage)

Select a name below, then press ⌘K (or ctrl-K). The palette opens with your selection as the query, already filtered — and the text sits selected in the input, so one keystroke replaces a wrong guess.

Memory led the session: SK Hynix carried the basket while Samsung faded, Tokyo Electron held its gap, and TSMC printed a quiet new high. The Robotics thesis absorbed all of it without a rebalance.

lineage
Inferring intent: selection → query. Focus returns where it came from.
use for
⌘K on every page.
not for
Selections over ~40 chars — that’s prose, not a query.
reduced
Opens and closes with no travel.

IX · The law we already had

Not a component — a number, and where it came from.

Cascadestagger MOTION_KIT.LAG · MOTION_KIT.cascade(nodes)

The mark's strokes ride one sine with a per-line lag of 0.10 rad at a speed of 0.0013 rad/ms. That is 77ms. The load storyboard's rail-card stagger, chosen separately and by eye, is 70ms. They are the same figure. The interface has been arriving on the mark's own wave the whole time, and nobody wrote it down.

Open orders
SK Hynixbuy limit · at ₩ 196,000120
Tokyo Electronsell limit · at ¥ 24,80040
TSMCbuy limit · at NT$ 1,010200
Advantestbuy limit · at ¥ 7,24075
lineage
lineLag 0.10 ÷ speed 0.0013 = 77ms, from mountMark's frame loop.
use for
Every list and card rail that arrives. It only writes --mo-d.
not for
Long lists. The storyboard caps at 8.
reduced
css/motion.css already kills the board.

What this kit deliberately does not have

X · Where these go

Nothing on this page has been wired into a product surface yet — this is the proposal. Ranked roughly by how much each one buys.

elementsurfacereplaces
ArcTrade ticket, between submit and clear; next-auction countdown in the top barThe .pending tick-dot doing duty for a wait we can actually measure
FieldLobby holdings · Asset metrics strip · Explore tiles · Scout panel.kit-skel's shimmer bar
ThinkScout fetching · ⌘K searching · Asset news & earnings tabsNothing — these surfaces currently show a blank while they load
PingLive price on Asset and Home · market-open state · a thesis mid-rebalanceNothing — "is this live?" is currently unanswerable
Rule · crestOrder filled · deposit landed · thesis copiedToast alone, which scrolls past unnoticed
TrailDeposit in flight · withdrawal settling (Activity + top bar)A static "pending" chip
TallyDeposit queue position · multi-step withdrawal"Position 3 in queue" as bare text
WashColorway change · range re-quote · portfolio refreshA hard value swap
Time machineActivity header · Story.htmlA static date filter
Auction dialTrade ticket · settling rowsNothing — past clearings are currently invisible
Clipboard depositDeposit / withdraw sheetsTyping an amount you just copied
⌘K inference⌘K everywhereRetyping a name you can see
Clock hintWherever the world clock landsAn undiscovered drag
RipplePrimary CTAs and the trade side buttonsNothing — additive
SwimSidebar wordmark · boot splash · Save.htmlNothing — additive
CascadeEverywhere the storyboard already staggers70ms → 77ms, and the number gets a reason
js/motion-kit.js · css/motion-kit.css — one rAF ticker, every specimen rests off-screen.
The component gallery: kit.html · laws: DESIGN.md → Round-2 audit laws