Motion
Motion shows users what changed and where things went. Every animation in Senco must be purposeful — never decorative.
There is a second reason motion is tightly constrained here. Senco's audience runs mid-range Android hardware on unreliable connections, and animation is the first thing that degrades: a 300ms transition that drops to 8fps reads as the app freezing at the exact moment a user is confirming a payment. Motion is a budget, and this page is where it is spent.
Duration tokens
Duration tokens define how long an animation lasts. Shorter durations suit small, frequent interactions; longer durations suit larger, less frequent transitions.
| Token | Value | Usage |
|---|---|---|
--ds-duration-instant | 50ms | Button press, toggle, checkbox |
--ds-duration-fast | 100ms | Dropdown open, tooltip appear, hover state |
--ds-duration-normal | 180ms | Panel slide, toast enter/exit, accordion |
--ds-duration-slow | 300ms | Page transition, modal open/close |
--ds-duration-slower | 500ms | Skeleton fade, complex transitions |
--ds-transition composes the default pair — 180ms cubic-bezier(0.4, 0, 0.2, 1) — and is what a component should reach for when it has no reason to choose otherwise.
Choosing a duration
- Smaller elements animate faster. A checkbox toggle should feel instantaneous.
- Larger surfaces use longer durations so the eye can track the change.
- Frequent actions (e.g., hover) should use fast durations to avoid feeling sluggish.
- Infrequent actions (e.g., page transitions) can afford slower, more expressive timing.
Rule of thumb: the duration scales with the distance travelled, not with the importance of the element. An important button that moves 4px still animates at 50ms.
The 300ms ceiling
Nothing in Senco animates longer than 300ms except skeleton fades. Above roughly 400ms a transition stops reading as feedback and starts reading as latency, and in a transfer app the user cannot tell the difference between a slow animation and a slow network. When in doubt the interface should feel abrupt rather than uncertain.
--ds-duration-slower (500ms) exists only for skeleton crossfades, where the slowness is the point — a skeleton that snaps to content flashes.
Subtle 150ms ease on hover. Functional feedback without distraction.
Flashy 2s bouncing animation. Distracting, inaccessible, and unprofessional.
Easing curves
Easing curves control the acceleration profile of an animation. Senco uses four curves.
| Token | Value | Usage |
|---|---|---|
--ds-ease-default | cubic-bezier(0.4, 0, 0.2, 1) | Most transitions — elements moving between states |
--ds-ease-out | cubic-bezier(0, 0, 0.2, 1) | Elements appearing (entrance) — decelerate into place |
--ds-ease-in | cubic-bezier(0.4, 0, 1, 1) | Elements leaving (exit) — accelerate out |
--ds-ease-bounce | cubic-bezier(0.34, 1.56, 0.64, 1) | Playful feedback (success checkmark, toggle snap) |
When to use each curve
- Standard is the default. Use it for any state change that doesn't involve an element entering or leaving the DOM.
- Entrance for modals opening, toasts appearing, dropdowns expanding.
- Exit for modals closing, toasts dismissing, dropdowns collapsing.
Entrance decelerates because the element is arriving and the eye needs to catch it. Exit accelerates because the element is already understood and lingering on it wastes the user's attention.
Where bounce is allowed
--ds-ease-bounce overshoots, and overshoot in a money app reads as unseriousness. It is permitted in exactly two places:
| Allowed | Not allowed |
|---|---|
| Success checkmark on a completed transfer | Any error, warning, or failed state |
| Toggle and switch snap | Modal, sheet, or page transitions |
A transfer completing is the one moment in the product where a small celebration is earned. Everywhere else, bounce makes a financial interface feel like it is playing.
Reduced motion
Users who enable prefers-reduced-motion on their OS must never see animations that could cause discomfort. Senco respects this setting globally.
The implementation is a single Platform-layer block, not a per-component guard:
@media (prefers-reduced-motion: reduce) {
.ds-layout *,
.ds-layout *::before,
.ds-layout *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Handling it once means a component written next year is compliant without its author knowing this rule exists. Durations collapse to 0.01ms rather than 0 so that transitionend and animationend listeners still fire — setting 0 silently breaks any component whose logic waits for an animation to finish.
What to simplify
| Category | Default | Reduced motion |
|---|---|---|
| Transitions (opacity, color) | Animate normally | Instant |
| Slide/transform animations | Animate with easing | Replace with opacity-only fade |
| Loading spinners | Continuous rotation, 1s linear | Rotation stops; the glyph stays visible and the label carries the state |
| Auto-playing carousels | Animate between slides | Pause, show manual controls |
| Status timeline pulse | Pulsing active dot | Static dot, state carried by color + icon + label |
The platform block above does not exempt spinners: animation-iteration-count: 1 at 0.01ms stops every rotation. That is deliberate — one mechanism, no per-component escape hatches. What keeps a pending state informative is that every spinner in the system is paired with a text label (see Inline loading), so a static glyph plus "Checking recipient details" says exactly what a rotating one did. Reduced motion means less motion, not less information.
The global block is a floor, not a ceiling
The Platform rule neutralizes transitions, but a component that communicates only through motion is still broken for these users. Any animated state must have a non-animated counterpart — which is the same triple-signaling rule that governs status color.
prefers-reduced-motion: reduce will see.Design and accessibility
- Purpose over polish. Every animation must answer: "What does this help the user understand?"
- No animation-only feedback. If motion communicates a state change, pair it with a visual change (color, icon, text) that persists after the animation ends.
- Respect user preference. Always honor
prefers-reduced-motion. Never override it. - Performance budget. Animate
transformandopacityfor anything that moves, andbackground-color,border-color,color, andbox-shadowfor state feedback. Never animatewidth,height,top,left,margin, orpadding— those trigger layout on every frame. Colour and shadow do not move an element, so they do not trigger layout; they are what every documented button, card, and input state change actually uses. - Test on low-end devices. Animations that are smooth on a MacBook Pro may stutter on a budget Android phone — Senco's primary audience.
- No motion above 3 flashes per second (WCAG 2.3.1). Nothing in the system currently approaches this, and nothing should be added that does.
- Motion never gates input. A user must be able to tap the primary action while an entrance animation is still running.
Do and don't
| Do | Don't |
|---|---|
Animate transform, opacity, colour, and shadow | Animate height to expand an accordion |
| Use bounce on a completed transfer | Use bounce on a failed one |
| Pair every spinner with a text label, so a static one still communicates | Ship a bare spinner that becomes meaningless once it stops |
| Keep transitions under 300ms | Add a 600ms "premium" page transition |
| Pair a state animation with a persistent visual change | Rely on a flash to communicate that a value updated |
Known gaps
| Item | Status |
|---|---|
| Motion token coverage | Durations and easings are tokenized; no tokens exist for delay or stagger |
| Low-end device testing | Stated as a requirement; no documented device matrix or frame-rate budget |
forced-colors interaction | Untested alongside reduced motion |