Animation
Motion tokens drive every transition; keyframes stay small and intentional; reduced-motion is respected at both the utility and document level.
Motion tokens
The whole animation system reads from four CSS custom properties. Swap the theme and every transition, hover and keyframe retimes without a single code change — Terminal snaps, Glass floats, Press drifts.
| Token | Role | Sketchbook default |
|---|---|---|
--duration-fast | Hover, focus, button press — interactions that must feel immediate. | 180ms |
--duration-normal | Enter / exit transitions, tooltip + popover reveals. | 240ms |
--duration-slow | Looping ambients — pulse, spin, shimmer, wiggle. | 380ms |
--ease | Default easing curve for every transition and non-linear keyframe. | cubic-bezier(0.33, 0.66, 0.33, 1) |
Reach for var(--duration-*) in your own SCSS or inline styles — never hard-code 180ms. A button that reads the token is a button that ships with every theme.
.my-btn { transition: background var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease); } .my-btn:hover { background: var(--brand-primary); color: var(--text-inverse); }
Keyframe library
_animations.scss ships a fixed vocabulary of twelve @keyframes. Every one is prefixed cia- so it never collides with host app animations. Durations default to --duration-normal; override per-utility with the -fast / -slow suffixes.
| Keyframe | What it animates | Typical use |
|---|---|---|
cia-fade-in | Opacity 0 → 1. | Content appearing after mount, skeleton-to-data swap. |
cia-fade-out | Opacity 1 → 0. | Dismissing toasts, closing overlays. |
cia-slide-up | Opacity + translateY(8px → 0). | Dropdowns, popovers, off-canvas drawers. |
cia-slide-down | Opacity + translateY(-8px → 0). | Header banners, inline alerts. |
cia-slide-left | Opacity + translateX(8px → 0). | Right-edge panels entering. |
cia-slide-right | Opacity + translateX(-8px → 0). | Left-edge panels entering. |
cia-scale-in | Opacity + scale(0.96 → 1). | Modal content, tooltip bodies. |
cia-pop | Scale beat: 1 → 1.06 → 1. | Notification badges, success icons. |
cia-pulse | Opacity loop 1 → 0.55 → 1. | Loading placeholders, live indicators. |
cia-spin | rotate(0 → 360deg), linear. | Spinners, refresh icons. |
cia-shimmer | Sweeps a gradient background across an element. | Skeletons with a moving highlight. |
cia-wiggle | Rotation jitter -2° → 2°. | Invalid-input feedback, attention shakes. |
Fade-in
.demo { animation: cia-fade-in var(--duration-slow) var(--ease) infinite alternate both; }
Slide-up
.demo { animation: cia-slide-up var(--duration-normal) var(--ease) both; }
Scale-in
.modal__body { animation: cia-scale-in var(--duration-normal) var(--ease) both; }
Pop
.badge--new { animation: cia-pop var(--duration-slow) var(--ease); }
Pulse
.live-dot { animation: cia-pulse var(--duration-slow) var(--ease) infinite; }
Spin
.spinner { animation: cia-spin var(--duration-slow) linear infinite; }
Wiggle
.field--invalid { animation: cia-wiggle var(--duration-slow) var(--ease); }
Shimmer
.skeleton { background: linear-gradient(90deg, var(--surface-sunk), var(--surface-raised), var(--surface-sunk)); background-size: 200% 100%; animation: cia-shimmer var(--duration-slow) linear infinite; }
Utility classes
Every keyframe is exposed as a .cia-anim-<name> utility, with -fast and -slow speed variants. Three loopers — spin, pulse, shimmer — ship with an infinite iteration count built in, because that is how they are used 99% of the time.
| Class | Effect | Duration |
|---|---|---|
.cia-anim-fade-in | Fade from transparent to opaque, runs once. | --duration-normal |
.cia-anim-fade-out | Fade from opaque to transparent, runs once. | --duration-normal |
.cia-anim-slide-up | Rise 8px while fading in, runs once. | --duration-normal |
.cia-anim-slide-down | Drop 8px while fading in, runs once. | --duration-normal |
.cia-anim-slide-left | Slide in from the right 8px while fading. | --duration-normal |
.cia-anim-slide-right | Slide in from the left 8px while fading. | --duration-normal |
.cia-anim-scale-in | Scale from 0.96 to 1 while fading in. | --duration-normal |
.cia-anim-pop | Brief scale beat up to 1.06, runs once. | --duration-normal |
.cia-anim-wiggle | Small rotation jitter, runs once. | --duration-normal |
.cia-anim-spin | Continuous 360° rotation. | --duration-slow, infinite, linear |
.cia-anim-pulse | Continuous opacity breathing. | --duration-slow, infinite |
.cia-anim-shimmer | Continuous gradient sweep across the element. | --duration-slow, infinite, linear |
.cia-anim-<name>-fast | Same animation, runs at --duration-fast. | --duration-fast |
.cia-anim-<name>-slow | Same animation, runs at --duration-slow. | --duration-slow |
Two examples the system leans on most — a pulsing live dot and a shimmering skeleton — rendered live with the exact behaviour the utility classes produce.
<span class="cia-anim-pulse">live</span> <div class="skeleton cia-anim-shimmer"></div>
Hover utilities
Four hover primitives cover the interactions the system needs most. Each one is a token-driven micro-transition — no motion values are hard-coded, so they adopt the active theme's timing curve.
| Class | Effect on hover | Reads from |
|---|---|---|
.cia-hover-lift | translateY(-2px) + medium shadow. | --duration-fast, --shadow-md |
.cia-hover-glow | Medium glow via box-shadow. | --duration-fast, --glow-md |
.cia-hover-press | translateY(1px) scale(0.99) — button-press feel. | --duration-fast |
.cia-hover-fade | Opacity dips to 0.7. | --duration-fast |
.cia-hover-glow only reads as a true glow under themes that ship non-transparent --glow-* values (Terminal, Graphite, Glass). Paper themes declare the glow token as a no-op so the class still applies without visual noise.
<a class="cia-card cia-hover-lift">…</a> <button class="cia-btn cia-hover-press">…</button> <img class="cia-hover-fade" src="/thumb.jpg" alt="">
Transition mixin
For authored components that need a token-aware transition declaration without typing it by hand, _mixins.scss exposes a variadic transition mixin. It accepts a list of properties interleaved with optional speed and easing keywords.
// signature @mixin transition($props...); // speed keywords: instant | fast | normal | slow | slower // easing keywords: linear | ease | ease-in | ease-out | ease-in-out | bounce | smooth // anything else is treated as a CSS property // usage .my-card { @include m.transition(background, color); // fast + smooth (defaults) } .my-modal { @include m.transition(opacity, transform, slow, ease-out); }
The mixin collapses all supplied properties into a single transition declaration and injects a @media (prefers-reduced-motion: reduce) block that turns the transition off entirely — so every authored component is automatically accessibility-aware.
Animations themselves are authored with the companion animate mixin from _animations.scss: @include animate(slide-up), @include animate(spin, $speed: slow, $iteration: infinite, $timing: linear). The mixin validates names at compile time and wires up reduced-motion for you.
Reduced motion
The system respects prefers-reduced-motion: reduce at three layers: the animate mixin collapses its own animation to 0.01ms, the transition mixin drops transitions entirely, and _animations.scss ships a global safety net that defuses every animation and transition on the page.
// scss/_animations.scss @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } }
The !important flag is deliberate — this is the one place in the system where it belongs. It guarantees that a host app's custom animation cannot override the user's OS-level motion preference.
Writing your own
A short checklist for new motion in a consuming app.
- Use the motion tokens. Type
var(--duration-fast)/var(--ease), never raw180msor a bespokecubic-bezier. Your animation re-skins when a theme swaps the tokens. - Prefer
transformandopacity. Both are GPU-accelerated and do not trigger layout. Animatingwidth,height, ortopwill jank on low-end hardware. - Wrap new keyframes in a reduced-motion check, or reach for the
animate/transitionmixins that already do it. A user who opts out of motion should opt out of your motion too. - Keep the vocabulary small. If the library's twelve keyframes cover the intent, use them — a consistent motion language reads as a single voice, not a grab-bag of easings.