CSS is Awesome

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.

TokenRoleSketchbook default
--duration-fastHover, focus, button press — interactions that must feel immediate.180ms
--duration-normalEnter / exit transitions, tooltip + popover reveals.240ms
--duration-slowLooping ambients — pulse, spin, shimmer, wiggle.380ms
--easeDefault 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.

KeyframeWhat it animatesTypical use
cia-fade-inOpacity 0 → 1.Content appearing after mount, skeleton-to-data swap.
cia-fade-outOpacity 1 → 0.Dismissing toasts, closing overlays.
cia-slide-upOpacity + translateY(8px → 0).Dropdowns, popovers, off-canvas drawers.
cia-slide-downOpacity + translateY(-8px → 0).Header banners, inline alerts.
cia-slide-leftOpacity + translateX(8px → 0).Right-edge panels entering.
cia-slide-rightOpacity + translateX(-8px → 0).Left-edge panels entering.
cia-scale-inOpacity + scale(0.96 → 1).Modal content, tooltip bodies.
cia-popScale beat: 1 → 1.06 → 1.Notification badges, success icons.
cia-pulseOpacity loop 1 → 0.55 → 1.Loading placeholders, live indicators.
cia-spinrotate(0 → 360deg), linear.Spinners, refresh icons.
cia-shimmerSweeps a gradient background across an element.Skeletons with a moving highlight.
cia-wiggleRotation jitter -2° → 2°.Invalid-input feedback, attention shakes.

Fade-in

fade
.demo {
  animation: cia-fade-in var(--duration-slow) var(--ease) infinite alternate both;
}

Slide-up

slide
.demo {
  animation: cia-slide-up var(--duration-normal) var(--ease) both;
}

Scale-in

scale
.modal__body {
  animation: cia-scale-in var(--duration-normal) var(--ease) both;
}

Pop

pop
.badge--new {
  animation: cia-pop var(--duration-slow) var(--ease);
}

Pulse

pulse
.live-dot {
  animation: cia-pulse var(--duration-slow) var(--ease) infinite;
}

Spin

spin
.spinner {
  animation: cia-spin var(--duration-slow) linear infinite;
}

Wiggle

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.

ClassEffectDuration
.cia-anim-fade-inFade from transparent to opaque, runs once.--duration-normal
.cia-anim-fade-outFade from opaque to transparent, runs once.--duration-normal
.cia-anim-slide-upRise 8px while fading in, runs once.--duration-normal
.cia-anim-slide-downDrop 8px while fading in, runs once.--duration-normal
.cia-anim-slide-leftSlide in from the right 8px while fading.--duration-normal
.cia-anim-slide-rightSlide in from the left 8px while fading.--duration-normal
.cia-anim-scale-inScale from 0.96 to 1 while fading in.--duration-normal
.cia-anim-popBrief scale beat up to 1.06, runs once.--duration-normal
.cia-anim-wiggleSmall rotation jitter, runs once.--duration-normal
.cia-anim-spinContinuous 360° rotation.--duration-slow, infinite, linear
.cia-anim-pulseContinuous opacity breathing.--duration-slow, infinite
.cia-anim-shimmerContinuous gradient sweep across the element.--duration-slow, infinite, linear
.cia-anim-<name>-fastSame animation, runs at --duration-fast.--duration-fast
.cia-anim-<name>-slowSame 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.

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

ClassEffect on hoverReads from
.cia-hover-lifttranslateY(-2px) + medium shadow.--duration-fast, --shadow-md
.cia-hover-glowMedium glow via box-shadow.--duration-fast, --glow-md
.cia-hover-presstranslateY(1px) scale(0.99) — button-press feel.--duration-fast
.cia-hover-fadeOpacity 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.

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

Theme