Motion kit
Small animations on the motion tokens: an error shake, a collapsible height, children that follow through, and a sheet slide.
Same-day delivery in Guatemala City for orders before noon.
Two to four business days anywhere else in the country.
Free over Q500.
Installation
$ pnpm dlx shadcn@latest add @ssntl/motion-kitUsage
/* 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.
## 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.Code
/* 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%;
}
}