Skip to content
Brand guide

Chapter 08

Motion

Motion in this brand confirms that something happened. It never performs.

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

TokenDurationEasingUse
Instant0ms—State that must read as a fact, not an animation
Fast120mscubic-bezier(0.2, 0, 0, 1)The default. Colour, opacity, border on hover and focus
Base200mssameDrawers, disclosure, position changes
Slow320mssameFull-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

PropertyVerdict
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.

KeyframeWhat it is for
sl-spinLoading spinner. 1s linear, infinite. The only sanctioned rotation
sl-pulseSkeleton placeholder. A 2s opacity pulse — not a shimmer sweep, which is a translate dressed up as a loading state
sl-indeterminateA progress bar with no known total
sl-fade-inThe backdrop behind a drawer or sheet. Opacity only
sl-riseDisclosure, menus and toast entry. A fade with a 4px settle
sl-slide-inDrawers and sheets. Starting edge is --sl-slide-from, so one keyframe covers both sides

Utilities

ClassDoes
.sl-transitionColour, opacity and shadow at 120ms. The default — reach for this, not transition-all
.sl-transition-moveTransform and opacity at 200ms. Position changes only
.sl-animate-spin · -pulse · -rise · -fade · -slide · -trackThe six keyframes above
.sl-focus-ringThe 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

This chapter as plain markdown: /docs/motion.md