Motion

Motion in Settld is a physical feedback system, not decoration. Every animation answers the question: why does this animate? Press states feel like real buttons. Sheet entrances feel like paper sliding in. Transitions confirm that the interface heard you. If an animation can't answer that question, it doesn't ship.

Philosophy

Motion is not about beauty — it's about communication. A button that scales down on press tells you it registered your tap. A sheet that eases in from below tells you it came from somewhere. These are not aesthetic choices; they are functional signals that happen to look good when done right.

Ask this before writing any animation: how often will a user see it? A button press happens dozens of times a day — it must be 140ms and instant. A sheet entrance happens a handful of times — 420ms with an iOS curve is earned. An onboarding animation happens once — it can be slow and expressive. The frequency determines the budget.

Never animate from scale(0). Nothing in the real world appears from nothing. Popovers start at scale(0.95) with opacity 0. Sheets slide up from translateY(100%). Cards that enter a list fade up from translateY(8px). The starting state should be a compressed or offset version of the final state — not a void.

All those unseen details — the press state that barely registers, the stagger that feels like items are arriving rather than appearing, the easing curve that starts fast and settles — combine into something users describe as "polished" without ever naming what they noticed. That's the goal.

Easing curves

Three curves. Each has a job. Out is the default — it starts fast and settles, which makes the interface feel immediately responsive. Spring overshoots slightly — for toggles and pops where a physical bounce signals a state change. iOS is the drawer/sheet curve — slower start, confident finish — it's how Apple's sheets feel.

Click the track to play each curve.

Out
cubic-bezier(0.23, 1, 0.32, 1)
--ease-out
Default for all UI — hovers, reveals, card entrances. Starts fast, tells the user the interface heard them immediately.
Spring
cubic-bezier(0.34, 1.56, 0.64, 1)
--ease-spring
Overshoots and snaps back. For toggles, chips being selected, celebrate states. The overshoot signals that a state changed — not just a position.
iOS
cubic-bezier(0.32, 0.72, 0, 1)
--ease-ios
Sheet and drawer entrance curve. Slightly heavier start, smooth confident finish. It's how Apple presents bottom sheets — familiar, physical, unhurried.

Duration tokens

Three durations. Fast for interactions, medium for transitions, slow for structural changes. Everything stays under 420ms — anything longer makes the UI feel like it's loading, not animating. Click each track to feel the difference.

Fast
140ms
Button hover, press feedback, small tooltip appearance. If the user can cause it faster than 140ms repeatedly, use this duration.
Med
240ms
Card transitions, popovers, dropdown appearance, toast entrance. The default transition length — fast enough to feel snappy, slow enough to be perceived.
Slow
420ms
Sheet and drawer entrances, the split-bar resize, mode switches. Only when something structurally large moves — the duration earns its weight.

Press states

Every tappable element — buttons, cards, list items, chips — gets scale(0.97) on :active. This is not optional. Without a press state, the interface feels like it didn't hear you. With it, the tap has physical weight. 3% is enough to register; more than that looks broken.

Press the cards below.

🍜
✈︎
Settle up →
/* Press state — every interactive element */
.card {
  transition: transform var(--d-fast) var(--ease-out),
              box-shadow var(--d-fast) var(--ease-out);
}
.card:active {
  transform: scale(0.97);
  box-shadow: var(--shadow-sm);
}

/* Never use transition: all — enumerate exactly */
/* Bad:  transition: all 140ms ease-out; */
/* Good: transition: transform 140ms var(--ease-out), background 140ms var(--ease-out); */

Hover gating

Hover animations are gated behind @media (hover: hover) and (pointer: fine). On touch devices, :hover fires on tap and stays sticky — so hover fills and transforms would get stuck on. The media query ensures hover effects only run where a real cursor exists.

/* Gate all hover transforms and fills */
@media (hover: hover) and (pointer: fine) {
  .card:hover {
    background: var(--s-warm);
    box-shadow: var(--shadow-md);
  }
  .btn-primary:hover {
    background: var(--teal-deep);
  }
}

/* Press states (:active) do NOT need this gate —
   they fire correctly on touch. Only hover does. */

Scroll reveal & stagger

When multiple elements enter together — a list loading, cards appearing after a filter — stagger their appearance 50–80ms apart. Everything appearing at once looks like a hard cut. Staggered entries look like items arriving, which feels natural and gives the eye a path to follow.

Keep stagger delays short. Long stagger makes the interface feel sluggish. The animation is decorative — never block interaction while stagger plays.

Farzi Cafe dinner — ₹1,400 · Kabir paid
Goa Airbnb deposit — ₹12,000 · Priya paid
Swiggy order — ₹680 · Rohan paid
Auto from airport — ₹340 · Aarav paid
/* Stagger: 50-80ms between siblings */
.list-item {
  opacity: 0;
  transform: translateY(8px);
  animation: fade-up 280ms var(--ease-out) forwards;
}
.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 60ms; }
.list-item:nth-child(3) { animation-delay: 120ms; }
.list-item:nth-child(4) { animation-delay: 180ms; }

@keyframes fade-up {
  to { opacity: 1; transform: translateY(0); }
}

Entry animation rules

Elements that appear must enter from a plausible physical state. Popovers and tooltips scale up from near their final size. Toasts slide in from the direction they'll be dismissed to. Sheets enter from below — that's where the thumb is. Never reveal from scale(0).

ElementEnter fromDurationEasing
Button pressscale(1) → scale(0.97) → scale(1)fast (140ms)--ease-out
Popover / menuscale(0.95) + opacity 0med (240ms)--ease-out
ToasttranslateY(100%) + opacity 0med (240ms)--ease-out
Modalscale(0.96) + opacity 0, centeredmed (240ms)--ease-out
Sheet / drawertranslateY(100%)slow (420ms)--ease-ios
List items (scroll reveal)translateY(8px) + opacity 0280ms staggered--ease-out
Toggle / chip (state change)scale(0.95) + spring overshootmed (240ms)--ease-spring

Token reference

TokenValuePurpose
--ease-outcubic-bezier(0.23, 1, 0.32, 1)Default for all UI transitions
--ease-springcubic-bezier(0.34, 1.56, 0.64, 1)Toggles, state pops, celebration moments
--ease-ioscubic-bezier(0.32, 0.72, 0, 1)Sheets and drawers
--d-fast140msHover, press, small reveals
--d-med240msPopovers, cards, toasts
--d-slow420msSheets, mode switches, bar resizes

Usage

Do Enumerate transition properties explicitly. Use scale(0.97) on :active for every pressable element. Gate hover animations behind the pointer media query. Start entries from a near-final state, never from void.
Don't Don't use transition: all — it animates layout properties (padding, border) and causes jank. Don't animate from scale(0). Don't autoplay ambient animations that users see on every visit — they become noise.
Do Use CSS transitions over keyframes for anything that can be triggered in quick succession — they interrupt and retarget smoothly. Toasts are the canonical example: rapid fire toasts with transitions stack gracefully; keyframes restart from scratch.
Don't Don't animate keyboard-initiated actions — actions triggered by keyboard shortcuts happen dozens of times a day and the animation feels like lag, not polish. The command palette doesn't animate. Neither does tab navigation between list items.
Do Respect prefers-reduced-motion. Keep opacity and color transitions (they aid comprehension). Remove position and scale animations. Reduced motion means less motion, not no feedback.
Don't Don't use ease-in for UI animations. It starts slow and makes the interface feel laggy before it moves. A 240ms ease-in dropdown feels slower than 240ms ease-out because the delay hits right when the user is watching.

Accessibility

prefers-reduced-motion Always respect the user's motion preference. Wrap position-based animations in a @media (prefers-reduced-motion: no-preference) guard. Leave color and opacity transitions intact — they carry meaning. The press state scale(0.97) is so small it's safe to keep even in reduced motion.
@media (prefers-reduced-motion: no-preference) {
  .sheet {
    transition: transform var(--d-slow) var(--ease-ios);
  }
  .list-item {
    animation: fade-up 280ms var(--ease-out) forwards;
  }
}

/* Safe in all modes — opacity carries meaning */
.toast {
  transition: opacity var(--d-med) var(--ease-out);
}

/* Safe in all modes — press state is barely perceptible */
.btn:active {
  transform: scale(0.97);
}