Skip to content
ssntl/ui

Motion kit

Small animations on the motion tokens: an error shake, a collapsible height, children that follow through, and a sheet slide.

Installation

$ pnpm dlx shadcn@latest add @ssntl/motion-kit

Usage

css
/* app/globals.css */
@import "../styles/ssntl/motion-kit.css";

<input class={invalid ? "shake" : ""} aria-invalid={invalid} />
<div class="collapsible" data-state="open"><div class="follow-through">…</div></div>

Rules

What this component expects from you, as it ships in styles/ssntl/motion-kit.md next to the code. The full file adds when to use it, keyboard, the UI foundations it applies and every shared rule.

styles/ssntl/motion-kit.md
## Rules for Motion kit

- `shake`: ±4px (spacing-1) over `--duration-menu`; pair it with `aria-invalid` and a message, since it never runs with reduced motion.
- `collapsible` animates a Radix disclosure's height; `follow-through` lifts its children in 40ms apart, after the container.
- `sheet-in` and `sheet-out` slide a panel from the bottom edge, out faster than in.
- Every animation is off under reduced motion; no state lives in motion alone.
Open the full rules file (opens in a new tab)

Code

styles/ssntl/motion-kit.css
/* Motion kit: small, purposeful animations built on the motion tokens.
   Every one turns off under reduced motion, and none carries state on its
   own: a color, an icon or a word stays behind when it doesn't run.

     shake               a noticeable but gentle error nudge
     collapsible         height for a disclosure (Radix --*-content-height)
     follow-through      children enter just after their container */

/* Noticeable errors: ±4px (spacing-1) over --duration-menu. Big enough to
   catch the eye, too small to jar; the message explains the error. */
@utility shake {
  @media (prefers-reduced-motion: no-preference) {
    animation: shake var(--duration-menu) var(--ease-out);
  }
}

@keyframes shake {
  20% {
    translate: calc(var(--spacing) * -1) 0;
  }
  45% {
    translate: var(--spacing) 0;
  }
  70% {
    translate: calc(var(--spacing) * -0.5) 0;
  }
}

/* Follow through & overlap: the container opens first, then its rows lift
   in 40ms apart, so the motion settles instead of landing at once. */
@utility collapsible {
  overflow: hidden;
  &[data-state="open"] {
    animation: collapse-open var(--duration-menu) var(--ease-out);
  }
  &[data-state="closed"] {
    animation: collapse-close var(--duration-fast) var(--ease-out);
  }
  @media (prefers-reduced-motion: reduce) {
    animation: none !important;
  }
}

/* Put follow-through on the element inside an open disclosure (Radix sets
   data-state="open" on its parent). */
@utility follow-through {
  --follow-delay: calc(var(--duration-fast) * 0.66);
  --follow-step: 40ms;
  @media (prefers-reduced-motion: no-preference) {
    [data-state="open"] > & > * {
      animation: follow-in var(--duration-menu) var(--ease-out) both;
      animation-delay: var(--follow-delay);
    }
    [data-state="open"] > & > :nth-child(2) {
      animation-delay: calc(var(--follow-delay) + var(--follow-step));
    }
    [data-state="open"] > & > :nth-child(3) {
      animation-delay: calc(var(--follow-delay) + var(--follow-step) * 2);
    }
    [data-state="open"] > & > :nth-child(n + 4) {
      animation-delay: calc(var(--follow-delay) + var(--follow-step) * 3);
    }
  }
}

@keyframes collapse-open {
  from {
    height: 0;
  }
  to {
    height: var(--radix-accordion-content-height, auto);
  }
}

@keyframes collapse-close {
  from {
    height: var(--radix-accordion-content-height, auto);
  }
  to {
    height: 0;
  }
}

@keyframes follow-in {
  from {
    opacity: 0;
    translate: 0 calc(var(--es-lift) * -0.5);
  }
}

/* Sheets slide up from the bottom edge and leave faster than they came. */
@utility sheet-in {
  animation: sheet-in var(--duration-panel) var(--ease-out);
  @media (prefers-reduced-motion: reduce) {
    animation-name: fade-in;
  }
}

@utility sheet-out {
  animation: sheet-out var(--duration-menu) var(--ease-out) forwards;
  @media (prefers-reduced-motion: reduce) {
    animation-name: fade-out;
  }
}

@keyframes sheet-in {
  from {
    translate: 0 100%;
  }
}

@keyframes sheet-out {
  to {
    translate: 0 100%;
  }
}