Motion

Motion specimens

09specimens

Micro-animations collected from sites worth learning from, plus our own. Each one is rebuilt live with its timeline, why it works and the core code. Specimens tagged In use run the real code from css/styles.css and js/nav-button-motion.js.

No. 001

Grab-and-drag gesture hint

AffordanceIdle loopIcon swap

Drag the stage. The hint steps aside, then returns after 2.5 s idle.

Timeline · one 2.4 s loop

rest
grab
drag ←
let go
return
0.00.61.21.82.4 s
Trigger
Idle loop on load, until the first drag
Moves
Open hand → fist (scale 0.9), ~7 px drift left, release, ease back
Chevrons
Static. They name the axis, the hand acts it out
Easing
cubic-bezier(.45,0,.25,1) · estimated from the recording

Why it works

  • It shows the verb instead of writing "drag to rotate". No reading, no language.
  • The open-to-fist swap is the grip moment. That beat sells "grab" more than the movement does.
  • Tiny travel and a long rest keep it calm enough to sit in the viewport without nagging.
  • The pill sits on top of the interactive area, so the cue points at where to act.

Core CSS

.hand { animation: drag 2.4s cubic-bezier(.45,0,.25,1) infinite; }
.hand .open { animation: open 2.4s linear infinite; }
.hand .grab { animation: grab 2.4s linear infinite; }

@keyframes drag {
  0%, 28%  { transform: translateX(0)    scale(1);  }
  36%      { transform: translateX(0)    scale(.9); }
  62%      { transform: translateX(-7px) scale(.9); }
  70%      { transform: translateX(-7px) scale(1);  }
  90%,100% { transform: translateX(0)    scale(1);  }
}
@keyframes open { 0%,28% {opacity:1} 33%,64% {opacity:0} 69%,100% {opacity:1} }
@keyframes grab { 0%,28% {opacity:0} 33%,64% {opacity:1} 69%,100% {opacity:0} }

/* hide after first interaction */
.hint.gone { opacity: 0; transform: translateY(8px) scale(.96); }

threejs-journey.com · spotted 1 Oct 2026 · rebuilt from a screen recording

No. 002

Focus-mode choreography

Layout transitionStaggerClick

Click the scene to open, × to close.

Sequence

fademoves
Trigger
Click on the 3D scene or the hint pill
Order
Old content leaves → scene travels → controls arrive → new content staggers in
Mover
Only the scene panel changes position. Everything else only fades and shifts a few px
Stagger
30 ms when leaving, 70–80 ms when arriving · estimated from the recording

Why it works

  • It runs as a relay, not all at once. Each beat starts as the last one settles, so there's only ever one thing to watch.
  • Exits are short and go first. Entrances are slower and come last. Leaving costs nothing, arriving gets attention.
  • One element travels. The scene keeps its identity and position in your head, so you never lose your place.
  • The camera turns while the panel slides. It reads as the object turning toward you, not a box being pushed aside.
  • The pill stays put and changes its job: the hint becomes the level switcher.
  • Closing mirrors opening but runs faster. Going back should feel cheaper than exploring.

Core CSS · the delay lives on the state you're going to

/* hero: leave first and fast, come back last */
.hero > *        { transition: opacity .4s, transform .4s;
                   transition-delay: calc(.9s + var(--i) * 70ms); }
.focus .hero > * { opacity: 0; transform: translateY(-8px);
                   transition-duration: .3s;
                   transition-delay: calc(var(--i) * 30ms); }

/* the one mover */
.panel        { left: 46%; right: 0;
                transition: left .8s cubic-bezier(.65,0,.25,1) .25s,
                            right .8s cubic-bezier(.65,0,.25,1) .25s; }
.focus .panel { left: 0; right: 30%; transition-delay: .3s; }

/* details: arrive last, top to bottom */
.details > *        { opacity: 0; transform: translateY(8px);
                      transition: opacity .25s, transform .25s; }
.focus .details > * { opacity: 1; transform: none;
                      transition-duration: .45s;
                      transition-delay: calc(1.3s + var(--i) * 80ms); }

threejs-journey.com · spotted 1 Oct 2026 · rebuilt from a screen recording

No. 003

Spin-and-collapse close button

HoverClickIn use
Payment

Hover to spin. Click to collapse; the demo sheet stays, so the button pops back like at the "Discard?" prompt.

Hover
× turns 90° in 0.45 s with a slight overshoot. Rotated 90°, an × looks the same, so it lands back on ×
Click
Icon shrinks first (≈0.17 s), then the button collapses to a dot and vanishes (0.28 s, accelerating)
Easing
Spin cubic-bezier(.34,1.45,.64,1), collapse cubic-bezier(.55,0,.75,0)
Tokens
--close-spin-duration, --close-spin-ease, --close-collapse-duration, --close-collapse-ease

Why it works

  • The spin confirms on hover that this thing reacts, without changing what it means.
  • On click, the button removes itself first. You see the result of your action at the spot you clicked.
  • Two-stage collapse (icon, then shape) reads as deliberate rather than a flicker.
  • Collapse uses ease-in: it accelerates away, which suits something leaving.

Applied in banking-app

  • Every button whose direct icon is the shared #i-x symbol, via :has(). No markup changes.
  • Collapse runs 0.28 s, the same as the sheet's own close, so both finish together.
  • If the panel stays open (the "Discard transfer?" prompt), the button pops back in.

Core CSS · css/styles.css › Close button motion

:is(button, a):has(> svg > use[href="#i-x"]) > svg {
  transition: transform var(--close-spin-duration) var(--close-spin-ease);
}
:is(button, a):has(> svg > use[href="#i-x"]):hover > svg {
  transform: rotate(90deg);
}

:is(button, a).is-dismissing {
  animation: uz-close-collapse var(--close-collapse-duration)
             var(--close-collapse-ease) forwards;
}
:is(button, a).is-dismissing > svg {
  animation: uz-close-icon-out calc(var(--close-collapse-duration) * .6)
             ease-in forwards;
}
@keyframes uz-close-collapse {
  35%  { transform: scale(.94); }
  85%  { transform: scale(.12); opacity: 1; }
  100% { transform: scale(0);   opacity: 0; }
}

threejs-journey.com · spotted 1 Oct 2026 · click handling in js/nav-button-motion.js

No. 004

Slide-through back arrow

HoverClickOwn designIn use
Step 3 · Summary

Hover to nudge. Click to step back; it loops 3 → 1.

Hover
Arrow leans 3 px the way it points, with a springy overshoot (0.35 s)
Click
Exits 12 px left and fades (ease-in), jumps right, slides back in (ease-out, slight overshoot). 0.42 s total
Lands
On the hover position via --back-x, so there is no jump when the animation ends under the cursor
Applies to
.modal__back (previous step in flows) and .view__back (page back)
Idle
Breathes left with the shared Arrow nudge (No. 009), paused while pointed at. The nudge uses translate, the hover lean and step-back use transform, so they add up

Why it works

  • The hover nudge previews the direction. The button says "back" before you click.
  • On click the arrow travels the way the content goes, so the motion matches the step change.
  • Back usually stays on screen. Re-entering from the other side resets it for the next tap instead of removing it.
  • Small distances (3 px, 12 px) keep it quick for repeated use in multi-step flows.

Core CSS · css/styles.css › Back button motion

:is(.modal__back, .view__back) { --back-x: 0px; }
:is(.modal__back, .view__back):hover { --back-x: var(--back-nudge); } /* -3px */
:is(.modal__back, .view__back) > svg {
  transform: translateX(var(--back-x));
  transition: transform .35s var(--back-nudge-ease);
}

.is-stepping-back > svg { animation: uz-back-step var(--back-step-duration) linear; }
@keyframes uz-back-step {
  0%   { transform: translateX(var(--back-x)); opacity: 1;
         animation-timing-function: cubic-bezier(.55,0,1,.45); }
  35%  { transform: translateX(-12px); opacity: 0; }
  36%  { transform: translateX(12px);  opacity: 0;
         animation-timing-function: cubic-bezier(.2,.9,.3,1.15); }
  100% { transform: translateX(var(--back-x)); opacity: 1; }
}

Own design, made to pair with No. 003 · click handling in js/nav-button-motion.js

No. 005

Close button build-up

View transitionPairs with 003In use

The sheet slides in, then the button builds up.

Trigger
The sheet has finished its slide-in, not when it starts
Button
Separator-tinted dot → 108 % → 100 %, 0.4 s ease-in-out, from 0.3 s. The tint fades to transparent
Icon
× spins in from −180° while scaling 0 → 100 % with an overshoot, 70 ms behind the button
Relation
No. 003 played in reverse: the exit shrinks icon first, the entrance grows the button first

Why it works

  • The control arrives after the content has settled, so nothing competes with the view transition.
  • A late close button doesn't hurt: nobody wants to close a view in its first 300 ms.
  • Entrance and exit mirror each other. The button feels like one object that appears and disappears.
  • Button first, icon second reads as "container, then content".

Applied in banking-app

  • Every sheet opens by removing .modal-shell--offscreen. One observer catches that and adds .is-entering to the close button inside.
  • Fill mode backwards only, so the hover spin from No. 003 still works afterwards.
  • A click mid-entrance jumps the entrance to its end, then the collapse plays.
  • Spec: Figma Des-Sys-Test-IV › Animations Transitions, node 345:6080.

Core CSS · css/styles.css › Close button motion

:is(button, a).is-entering {
  border-radius: 50%;
  animation: uz-close-enter var(--close-enter-duration) ease-in-out
             var(--close-enter-delay) backwards;
}
:is(button, a).is-entering > svg {
  animation: uz-close-icon-enter var(--close-enter-icon-duration)
             cubic-bezier(.2,.9,.3,1.2)
             calc(var(--close-enter-delay) + var(--close-enter-icon-lag)) backwards;
}
@keyframes uz-close-enter {
  0%   { transform: scale(0);    opacity: 0; background-color: var(--close-enter-fill); }
  10%  { transform: scale(.08);  opacity: 1; }   /* the dot */
  60%  { transform: scale(1.08); }               /* overshoot */
  100% { transform: scale(1);    background-color: transparent; }
}
@keyframes uz-close-icon-enter {
  from { transform: rotate(-180deg) scale(0); }
  to   { transform: rotate(0deg)    scale(1); }
}

threejs-journey.com · spotted 1 Oct 2026 · built to Claudius's Figma spec, 2 Oct 2026

No. 006

Proceed button in multi-step flows

Idle loopOwn designIn use
Step 2 of 3 · Amount & recipient
Step 3 of 3 · Summary

Both loop permanently. Hover pauses them.

Timeline · one 2 s loop, same for both

out · quick
back · slow
0.00.51.01.52.0 s
Steps between
Only the trailing arrow-right moves: out 6 px, then drifts back. The arrow says "there's more after this"
Last step
Only the pulse: a 4 dp ring in the button's own colour grows on every side, then contracts. No arrow, because this step executes the whole flow
Rhythm
Out quick (38 %, 0.76 s), back slow (62 %, 1.24 s). No overshoot, no hold
Easing
Out cubic-bezier(.3,0,.2,1), back cubic-bezier(.45,0,.4,1). Both slow into their end, so no turn hits hard
Tokens
--proceed-cycle 2 s, --proceed-grow 4px, --proceed-nudge 6px. The arrow uses the shared keyframes of No. 009

Why it works

  • Two different signals for two different promises: the arrow means "next step", the pulse means "this one does it".
  • A quick outward beat catches the eye; the long return lets it settle, so the loop breathes rather than nags.
  • Every turning point decelerates. Earlier versions returned at full speed and overshot, and read as hitting and bouncing.
  • The pulse is a box-shadow ring, not a scale, so the label stays perfectly still and readable.

Applied in banking-app

  • Payment, internal account transfer and buy & sell flows. js/cta-pulse.js marks the button as --proceed-next or --proceed-final and adds the arrow.
  • The flows rewrite the label from Confirm to Execute. An observer re-classifies the button when that happens.
  • Hover pauses the loop and hands over to the hover style. Disabled buttons and reduced motion stay still.
  • History: v1 scale + state layer → v2 symmetric breathing → v3 split, slow out / quick back (hit hard) → v4 soft turns → v5 reversed: quick out, slow back.
  • The arrow motion became a rule for every directional arrow in the app — see No. 009 Arrow nudge.

Core CSS · css/styles.css › Proceed button

:root { --proceed-cycle: 2000ms; --proceed-grow: 4px; --proceed-nudge: 6px; }

/* steps in between: the arrow — shared Arrow nudge (No. 009) */
.uz-btn--proceed-next .uz-btn__icon--trailing {
  --arrow-dir: 1;
  --arrow-nudge: var(--proceed-nudge);
  animation: uz-arrow-nudge var(--proceed-cycle) infinite;
}

/* last step: the pulse */
.uz-btn--proceed-final {
  --proceed-ring: var(--color-btn-primary-bg);
  animation: uz-proceed-pulse var(--proceed-cycle) infinite;
}
@keyframes uz-proceed-pulse {
  0%   { box-shadow: 0 0 0 0 var(--proceed-ring);
         animation-timing-function: cubic-bezier(.3,0,.2,1); }
  38%  { box-shadow: 0 0 0 var(--proceed-grow) var(--proceed-ring);
         animation-timing-function: cubic-bezier(.45,0,.4,1); }
  100% { box-shadow: 0 0 0 0 var(--proceed-ring); }
}

Own design for the UZ Bank banking app · tuned with Claudius, 7 Oct 2026 · not in Figma yet

No. 007

Step indicator in multi-step flows

ProgressOwn designIn use
Recipient

Step through the payment flow's four steps.

Anatomy
One segment per step (22 × 4 dp, 6 dp apart), centred 8 dp under the flow title. Filled up to and including the current step
Forward
The next segment fills left → right, 360 ms, the app's entry curve cubic-bezier(.22,1,.36,1)
Back
The segment you leave empties right → left, 240 ms, cubic-bezier(.45,0,.55,1). Leaving is quicker than arriving
Opening
No animation on first paint or when the sheet reopens: the state is simply there
Colours
Filled --color-fg, empty --color-separator, so it works in both themes
Tokens
--step-seg-width, --step-seg-height, --step-seg-gap, --step-fill-*, --step-empty-*

Why it works

  • People see how long the flow is before they commit, and how much is left at every step.
  • The fill travels in the reading direction, the same way the back arrow (No. 004) and the proceed arrow (No. 006) point.
  • Segments, not a bar: each step is a countable unit that matches a screen.
  • It sits under the title, so title and progress read as one line of information.

Applied in banking-app

  • Payment: Recipient · Amount · Time schedule · Summary → 4 segments. The recipient search before it is a pre-flow step: no indicator there, and closing it skips the "Discard payment?" dialog because nothing has been chosen yet. The indicator appears with the first segment filling once a recipient is picked.
  • Internal account transfer: Amount & recipient · Time schedule · Summary → 3 segments.
  • Buy & sell has a single step, so it gets none.
  • js/step-indicator.js only watches which .modal__step is active, so the flow scripts are untouched. Screen readers get a progressbar with "Step 2 of 4: Amount".
  • Not in Figma yet.

Core CSS · css/styles.css › Step indicator

.step-indicator { display: flex; justify-content: center; gap: var(--step-seg-gap); }
.step-indicator__seg {
  position: relative; overflow: hidden;
  width: var(--step-seg-width); height: var(--step-seg-height);
  border-radius: calc(var(--step-seg-height) / 2);
  background-color: var(--color-separator);
}
.step-indicator__seg::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  background-color: var(--color-fg);
  transform: scaleX(0); transform-origin: left center;
  transition: transform var(--step-fill-duration) var(--step-fill-ease);
}
.step-indicator__seg.is-filled::after { transform: scaleX(1); }

/* back: the leaving segment empties, quicker */
.step-indicator--back .step-indicator__seg::after {
  transition-duration: var(--step-empty-duration);
  transition-timing-function: var(--step-empty-ease);
}

Own design for the UZ Bank banking app · added 7 Oct 2026

No. 008

Wiggle — attention cue

AttentionOwn designIn use
AmountCHF 500.00
Execution dateToday
RecipientHans Meyer
Open payment! since 04.10.2026

There's an unpaid bill on this account. Pay it now to avoid reminder fees.

One wiggle, one pivot: every icon turns around the centre of its 24 × 24 box. Grouped pencils go one at a time, in a random order.

Timeline · one wiggle, 0.72 s

−14°
+10°
−7°
+4°
−1°
0
00.180.360.540.72 s
Motion
Rotation only: −14° → +10.5° → −7° → +4° → −1.4° → 0. Each swing smaller, every turn eased (cubic-bezier(.33,0,.67,1)), so it rings out instead of stopping
Pivot
General rule: always the centre of the icon container (50 % 50 % of the 24 × 24 box), so the whole vector rotates around one point — never a part of the drawing
Several
A group plays one at a time, in a random order each round: the next starts --wiggle-sequence-gap (120 ms) after the previous has settled
When
Shortly after the element has arrived, then again after 1.5 s of rest (counted from the moment the last icon has settled) while nobody reacts, at most 3 rounds. The status message is the exception: it keeps wiggling as long as it is there
Stops
For good on interaction: focus or typing in the search, any tap or key press in a flow sheet. The message only skips a round while it is hovered or focused
Tokens
--wiggle-duration 720 ms, --wiggle-angle 14°, --wiggle-rest 1.5 s, --wiggle-search-delay 700 ms, --wiggle-step-delay 500 ms, --wiggle-sequence-gap 120 ms

Why it works

  • Movement in the periphery is the strongest cue we have; a small rotation catches the eye without moving the layout.
  • It rings out rather than shaking: decreasing swings read as "something happened here", not as an error.
  • One pivot for every icon: turning around the centre keeps the icon in its place, so it reads as a nudge, not as a drawing that moves.
  • Random order in a group: no field looks more important than another, and the eye doesn't learn a pattern to ignore.
  • One motion for one meaning: wherever the app wants a first look, it uses this wiggle.

Applied in banking-app

  • Payment recipient search: the magnifier, starting 0.7 s after the sheet has landed (after the close button build-up, No. 005).
  • Status message: the bell / state icon, from the moment its content has landed and permanently after that (also when it is restored on another view). It replaces the earlier error-only icon pulse, for all three states.
  • Payment and internal account transfer flows: the edit pencils of the read-only fields on every step (recipient, amount, schedule, summary) — one at a time, random order, never together. Starts 0.5 s after the step is shown; any tap or key press in the sheet ends it until the next step.
  • Booking details (domestic, pending): the pencil next to the amount, same schedule, 0.5 s after the sheet is open.
  • js/wiggle.js holds the schedule (UZWiggle.play / UZWiggle.attend); the keyframes are in css/styles.css › Wiggle.
  • Reduced motion: no wiggle. Not in Figma yet.

Core CSS · css/styles.css › Wiggle

.uz-wiggle {
  transform-box: border-box;   /* the icon's own 24 × 24 box … */
  transform-origin: 50% 50%;   /* … rotating around its centre */
  animation: uz-wiggle var(--wiggle-duration) both;
}
@keyframes uz-wiggle {   /* every segment: cubic-bezier(.33,0,.67,1) */
  0%   { transform: rotate(0deg); }
  14%  { transform: rotate(calc(var(--wiggle-angle) * -1)); }
  32%  { transform: rotate(calc(var(--wiggle-angle) * .75)); }
  50%  { transform: rotate(calc(var(--wiggle-angle) * -.5)); }
  68%  { transform: rotate(calc(var(--wiggle-angle) * .28)); }
  84%  { transform: rotate(calc(var(--wiggle-angle) * -.1)); }
  100% { transform: rotate(0deg); }
}

Own design for the UZ Bank banking app · added 7 Oct 2026

No. 009

Arrow nudge — every arrow points the way

Idle loopNavigationOwn designIn use
Forward · deeper level → right
Back · previous step or higher level → left
Account details
Recipient

All arrows breathe on the same 2 s loop. Hover one to pause it.

Timeline · one 2 s loop

out · quick
back · slow
0.00.51.01.52.0 s
Rule
Every arrow that leads somewhere moves the way it points: forward / deeper level → right, back / previous step / higher level → left
Motion
6 px out in 38 % of the loop (cubic-bezier(.3,0,.2,1)), back in 62 % (cubic-bezier(.45,0,.4,1)). Soft turns, no overshoot, no hold
Forward
Any button or link whose direct icon is #i-arrow-right: Details, Show all, … The proceed button (No. 006) uses the same keyframes with its own cycle token
Back
.view__back (page back) and .modal__back (previous flow step)
Hover
Pauses the loop; the control's own hover takes over. Disabled and reduced motion: still
Not included
List-row chevrons (Offers rows) and carousel prev/next — many rows moving at once would be noise
Tokens
--arrow-nudge 6px, --arrow-cycle 2 s, --arrow-dir 1 / −1

Why it works

  • The arrow already says where you'll go; the motion says it a second time without words, in the direction of the page transition (Tectonics carousel: deeper = right, back = left).
  • One motion for one meaning across the app: the proceed arrow, Details, Show all and the back arrows all speak the same language.
  • Quick out catches the eye, the slow return lets it settle, so a page full of arrows still feels calm.
  • It uses the individual translate property, so it never fights the hover lean or the click step-back of the back arrow (No. 004).

Core CSS · css/styles.css › Arrow nudge

:root { --arrow-nudge: 6px; --arrow-cycle: 2000ms; }

@keyframes uz-arrow-nudge {
  0%   { translate: 0 0;
         animation-timing-function: cubic-bezier(.3,0,.2,1); }   /* out: quick */
  38%  { translate: calc(var(--arrow-nudge) * var(--arrow-dir, 1)) 0;
         animation-timing-function: cubic-bezier(.45,0,.4,1); }  /* back: slow */
  100% { translate: 0 0; }
}

/* forward / deeper: Details, Show all, … */
:is(a, button):not(.uz-btn--proceed):has(> svg > use[href="#i-arrow-right"]) > svg {
  --arrow-dir: 1;
  animation: uz-arrow-nudge var(--arrow-cycle) infinite;
}

/* back / higher level: page back and previous flow step */
:is(.view__back, .modal__back) > svg {
  --arrow-dir: -1;
  animation: uz-arrow-nudge var(--arrow-cycle) infinite;
}

/* pointing at it pauses the loop */
:is(a, button):has(> svg > use[href="#i-arrow-right"]):hover > svg,
:is(.view__back, .modal__back):hover > svg { animation-play-state: paused; }

Own design for the UZ Bank banking app · from the proceed arrow (No. 006) · added 7 Oct 2026 · not in Figma yet

Next specimen: send a screen recording and the URL, and it gets added here with its own live rebuild.