8.1 The principle
Since Labs is calm software for people who are busy. Animation is feedback, not personality. If a movement is there to be admired rather than to explain a change of state, cut it.
The test: would a person notice if this were instant? If the answer is no, make it instant. If the answer is "they would be confused about what just happened", that is what the motion is for.
8.2 The scale
| Token | Duration | Easing | Use |
|---|---|---|---|
| Instant | 0ms | — | State that must read as a fact, not an animation |
| Fast | 120ms | cubic-bezier(0.2, 0, 0, 1) | The default. Colour, opacity, border on hover and focus |
| Base | 200ms | same | Drawers, disclosure, position changes |
| Slow | 320ms | same | Full-surface transitions. The ceiling |
Nothing in this brand animates longer than 320ms. One easing curve throughout — an ease-out that starts immediately and settles. No bounce, no elastic, no spring.
8.3 What animates
| Property | Verdict |
|---|---|
color, background-color, border-color | ✅ The overwhelming majority of motion in the product |
opacity | ✅ Fades, disclosure |
transform: translate | ✅ Drawers and sheets only |
box-shadow | ✅ Card hover, sparingly |
width, height, top, left | ❌ Layout-thrashing. Use transform |
rotate, scale | ❌ Except a loading spinner |
Prefer transition-colors over transition-all. transition-all animates
properties you did not intend and produces the jelly effect that makes an
interface feel cheap.
8.4 Patterns
Hover — 120ms colour change. Never a size change; a control that grows under the cursor moves the thing you were about to click.
Focus — the ring appears instantly. Never fade a focus ring: a keyboard user tabbing quickly must see where they are without waiting.
Drawer / mobile sidebar — 200ms translateX, backdrop fades over the same
duration.
Modal — appears instantly. It is a mode change, and a fade makes the whole app feel slow.
Loading — a 1s linear spin for a spinner. For content, prefer a skeleton in
surface-mist with a 2s pulse over a spinner, and prefer nothing over a
skeleton if the wait is under ~300ms.
Route change — nothing. The page changes. Page transitions are a personality we have not earned.
8.5 Reduced motion
Non-negotiable. Ships in tokens/since-brand.css:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Anything driven by JavaScript must check the same signal:
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
Reduced motion means the end state, immediately — never a missing state.
The global rule above flattens durations, which is not enough on its own: an
infinite animation with a 0.01ms duration does not stop, it strobes. So
since-motion.css also stops the looping utilities outright and holds each at
the frame that reads as its resting state — a still spinner at 65% opacity, an
indeterminate bar that fills instead of travelling.
8.6 The transport
The rules above are only true if there is one place the keyframes live.
tokens/since-motion.css is it — import it after the brand tokens:
@import "@sincelabs/brand/tokens/since-brand.css";
@import "@sincelabs/brand/tokens/since-motion.css";
Six keyframes. That is the complete sanctioned set. Adding a seventh is a brand decision, not an implementation detail.
| Keyframe | What it is for |
|---|---|
sl-spin | Loading spinner. 1s linear, infinite. The only sanctioned rotation |
sl-pulse | Skeleton placeholder. A 2s opacity pulse — not a shimmer sweep, which is a translate dressed up as a loading state |
sl-indeterminate | A progress bar with no known total |
sl-fade-in | The backdrop behind a drawer or sheet. Opacity only |
sl-rise | Disclosure, menus and toast entry. A fade with a 4px settle |
sl-slide-in | Drawers and sheets. Starting edge is --sl-slide-from, so one keyframe covers both sides |
Utilities
| Class | Does |
|---|---|
.sl-transition | Colour, opacity and shadow at 120ms. The default — reach for this, not transition-all |
.sl-transition-move | Transform and opacity at 200ms. Position changes only |
.sl-animate-spin · -pulse · -rise · -fade · -slide · -track | The six keyframes above |
.sl-focus-ring | The 2px accent ring, offset 2px, with transition: none |
npm run check fails if tokens.json and since-motion.css disagree about
which keyframes exist, if anything non-looping animates past 320ms, or if a
second easing curve appears in the file.
8.7 Do and don't
Do
- ✅ 120ms colour transitions as the default
- ✅ Instant focus rings and instant modals
- ✅ One easing curve everywhere
- ✅ Honour
prefers-reduced-motion
Don't
- ❌
transition-all - ❌ Animate anything past 320ms
- ❌ Bounce, spring, or elastic easing
- ❌ Scale a control on hover
- ❌ Add a page transition