Motion specimens
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.
- 001Grab-and-drag hint
- 002Focus-mode choreography
- 003Spin-and-collapse close
- 004Slide-through back arrow
- 005Close button build-up
- 006Proceed button
- 007Step indicator
- 008Wiggle
- 009Arrow nudge
Grab-and-drag gesture hint
Drag the stage. The hint steps aside, then returns after 2.5 s idle.
Timeline · one 2.4 s loop
- 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
Focus-mode choreography
Click the scene to open, × to close.
Sequence
- 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
Spin-and-collapse close button
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), collapsecubic-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-xsymbol, 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
Slide-through back arrow
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 usetransform, 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
Close button build-up
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-enteringto the close button inside. - Fill mode
backwardsonly, 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
Proceed button in multi-step flows
Both loop permanently. Hover pauses them.
Timeline · one 2 s loop, same for both
- 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), backcubic-bezier(.45,0,.4,1). Both slow into their end, so no turn hits hard - Tokens
--proceed-cycle2 s,--proceed-grow4px,--proceed-nudge6px. 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.jsmarks the button as--proceed-nextor--proceed-finaland 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
Step indicator in multi-step flows
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.jsonly watches which.modal__stepis 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
Wiggle — attention cue
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
- 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-duration720 ms,--wiggle-angle14°,--wiggle-rest1.5 s,--wiggle-search-delay700 ms,--wiggle-step-delay500 ms,--wiggle-sequence-gap120 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.jsholds the schedule (UZWiggle.play/UZWiggle.attend); the keyframes are incss/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
Arrow nudge — every arrow points the way
All arrows breathe on the same 2 s loop. Hover one to pause it.
Timeline · one 2 s loop
- 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-nudge6px,--arrow-cycle2 s,--arrow-dir1 / −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
translateproperty, 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.