defuss-shadcn / Guides / Animations / motion
MotionATM
The shared entrance vocabulary for every component - decks, cards, lists, dialogs. CSS @keyframes are the source of truth (geometry, easing, reduced-motion); one attribute applies them declaratively, and a ~60-line controller (ddf$.entrance()) owns trigger/replay, timing overrides and the finished/cancel lifecycle through the browser's own CSSAnimation objects. No animation library.
On this page (7)
§Anatomy
One attribute is the entire API: data-df-entrance picks the effect, the --df-motion-* custom properties tune it (any ancestor, container or inline style - inline wins). The live grid below runs all fifteen; press Replay to re-fire them.
§The vocabulary
All fifteen entrances are one attribute each - they run on application (fresh DOM included; animations, unlike transitions, need no rendered from-state). Each plays for 1500 ms on an evenly paced ease-in-out, so the whole motion is visible (pop keeps its overshoot curve). Replay them with the button.
§Stagger + timing
data-df-stagger grades children by --df-motion-stagger-step (first 8, then capped) while each keeps its own direction; duration/ease/distance are plain custom properties - set them on any ancestor, container or element (inline wins). Run it again with the button.
§SVG draw-in
data-df-draw is its own primitive: pathLength=1 calibrates distance-along-path, so one dash rule (1 → 0) fits any geometry. Replay via ddf$.draw(pathEl) - the button does exactly that.
§JS controller
The imperative half: entrance() retriggers the same keyframes with per-call timing, returns a finished promise + cancel - no class toggling, no forced reflow. Unknown effects throw. Each call here plays for 1500 ms on an evenly paced ease-in-out. Pick an effect chip.
Motion is a static stylesheet plus a core-embedded controller - no per-element State API and no JavaScript file of its own; the runtime declares only default.
§Per-animation deep dives
Every animation also exists as an imperative df$.anim.* channel with paired in/out variants, orientation options (north/south/west/east), a play()/pause()/resume()/reset() lifecycle and optional scroll-bound progress (parallax) - each has its own page with live demos:
fadeIn / fadeOut - quiet opacity cross-fadeSlide Up
slideIn / slideOut - drift, four orientationsSlide Down
slideIn / slideOut from the north edgeSlide Left
slideIn / slideOut from the east edgeSlide Right
slideIn / slideOut from the west edgeZoom
zoomIn / zoomOut - scale with originZoom Out
shrink-in / grow-out, scale 1.1Pop
popIn / popOut - springy overshootSpin
spinIn / spinOut - half-turn rotationFlip
flipIn / flipOut - 3D rotateX/rotateYSkew
skewIn / skewOut - shear, four orientationsBlur
blurIn / blurOut - focus pullWipe
wipeIn / wipeOut - clip-path revealWipe Up
wipeIn / wipeOut from the south edgeIris
irisIn / irisOut - circle opens from any point
The composite blocksIn/blocksOut roll-over (N panels covering/uncovering an element with staggered delays) and the slide-board composition live on the Animation Canvas page.
§CSS view file
The entire vocabulary: the --df-motion-* tunables (defaults at :root, overridable by any ancestor), the direction → animation-name mapping with animation-composition: add, the fifteen df-enter-* keyframes, df-draw, the stagger grader and the reduced-motion flattening.
/* -- Motion component ------------------------------------------------- The shared entrance-animation vocabulary: CSS @keyframes are the single source of truth (geometry, easing, defaults, reduced-motion), driven either declaratively via [data-df-entrance] or imperatively through the JS controller `ddf$.entrance(el, effect, opts)` (src/shared/motion.ts - same functions under df$.shadcn.entrance). The JS never defines keyframes; it only triggers/retriggers, overrides timing vars and owns the finished/cancel lifecycle via the platform's CSSAnimation objects. Why animations (not transitions): they run deterministically when the attribute first applies - including on freshly inserted DOM - and they replay cleanly (cancel → play). Transitions need a rendered "from" state and never fire on initial application (the reason the first vocabulary demo showed a static grid). Why animation-composition: add - transform/filter keyframes COMPOSE with any pre-existing transform/filter on the element instead of replacing it, so entrances are safe on arbitrarily styled DOM (no transform inspection or reconstruction). The paired fade runs with `replace`. Limits: non-replaced inline boxes (display:inline spans) are not transformable per CSS - this is deliberate and NOT worked around (silent display changes would alter text wrapping). Wrap inline content or let the caller promote it. */@layer components { /* timing tunables - :root defaults so ANY ancestor (deck mount, container, inline style) can override by ordinary cascade; inline styles win last */ :root { --df-motion-duration: 1500ms; --df-motion-delay: 0ms; --df-motion-ease: cubic-bezier(0.16, 1, 0.3, 1); --df-motion-distance: 24px; --df-motion-scale-in: 0.9; --df-motion-scale-out: 1.1; --df-motion-blur: 12px; --df-motion-angle: 12deg; --df-motion-spin: -0.5turn; /* JS writes the element's CURRENT computed opacity here, so fading an opacity:.6 element ends at .6 - never brightens content it doesn't own */ --df-motion-base-opacity: 1; } /* one animation declaration, parameterized entirely by the vars above */ [data-df-entrance] { animation-duration: var(--df-motion-duration); animation-delay: var(--df-motion-delay); animation-timing-function: var(--df-motion-ease); animation-fill-mode: both; /* transform-channel entrances pair with the fade (composition: add keeps the element's own transform underneath); clip-path entrances need no fade - the clip alone reveals the content */ &[data-df-entrance="up"] { animation-name: df-enter-up, df-enter-fade; animation-composition: add, replace; } &[data-df-entrance="down"] { animation-name: df-enter-down, df-enter-fade; animation-composition: add, replace; } &[data-df-entrance="left"] { animation-name: df-enter-left, df-enter-fade; animation-composition: add, replace; } &[data-df-entrance="right"] { animation-name: df-enter-right, df-enter-fade; animation-composition: add, replace; } &[data-df-entrance="zoom"] { animation-name: df-enter-zoom, df-enter-fade; animation-composition: add, replace; } &[data-df-entrance="zoom-out"] { animation-name: df-enter-zoom-out, df-enter-fade; animation-composition: add, replace; } &[data-df-entrance="pop"] { animation-name: df-enter-pop, df-enter-fade; animation-composition: add, replace; } &[data-df-entrance="spin"] { animation-name: df-enter-spin, df-enter-fade; animation-composition: add, replace; } &[data-df-entrance="flip"] { animation-name: df-enter-flip, df-enter-fade; animation-composition: add, replace; } &[data-df-entrance="skew"] { animation-name: df-enter-skew, df-enter-fade; animation-composition: add, replace; } &[data-df-entrance="blur"] { animation-name: df-enter-blur, df-enter-fade; animation-composition: add, replace; } &[data-df-entrance="wipe"] { animation-name: df-enter-wipe; } &[data-df-entrance="wipe-up"] { animation-name: df-enter-wipe-up; } &[data-df-entrance="iris"] { animation-name: df-enter-iris; } &[data-df-entrance="fade"] { animation-name: df-enter-fade; } } /* stagger: children of a [data-df-stagger] container get graded delays (--df-motion-stagger-step per container) while keeping their own direction. ponytail: beyond the 8th child the delay caps at *7 - long lists should chunk into columns. An inline --df-motion-delay on a child still wins (inline beats any stylesheet rule). */ [data-df-stagger] { --df-motion-stagger-step: 120ms; & > :nth-child(1) { --df-motion-delay: calc(var(--df-motion-stagger-step) * 0); } & > :nth-child(2) { --df-motion-delay: calc(var(--df-motion-stagger-step) * 1); } & > :nth-child(3) { --df-motion-delay: calc(var(--df-motion-stagger-step) * 2); } & > :nth-child(4) { --df-motion-delay: calc(var(--df-motion-stagger-step) * 3); } & > :nth-child(5) { --df-motion-delay: calc(var(--df-motion-stagger-step) * 4); } & > :nth-child(6) { --df-motion-delay: calc(var(--df-motion-stagger-step) * 5); } & > :nth-child(7) { --df-motion-delay: calc(var(--df-motion-stagger-step) * 6); } & > :nth-child(n + 8) { --df-motion-delay: calc(var(--df-motion-stagger-step) * 7); } } /* SVG draw-in - a distinct primitive, not a DOM entrance: pathLength="1" calibrates distance-along-path so ONE dash rule (1 → 0) fits every geometry regardless of actual path length. (The CSS `path-length` property stays experimental - the SVG attribute is the portable form.) */ [data-df-draw][pathLength="1"] { stroke-dasharray: 1; stroke-dashoffset: 1; animation: df-draw var(--df-motion-duration) var(--df-motion-ease) var(--df-motion-delay) both; } /* vocabulary - every direction is exactly one keyframes rule */ @keyframes df-enter-fade { from { opacity: 0; } to { opacity: var(--df-motion-base-opacity); } } /* "up" travels upward into its final position (from below) */ @keyframes df-enter-up { from { transform: translateY(var(--df-motion-distance)); } to { transform: translateY(0); } } @keyframes df-enter-down { from { transform: translateY(calc(-1 * var(--df-motion-distance))); } to { transform: translateY(0); } } @keyframes df-enter-left { from { transform: translateX(var(--df-motion-distance)); } to { transform: translateX(0); } } @keyframes df-enter-right { from { transform: translateX(calc(-1 * var(--df-motion-distance))); } to { transform: translateX(0); } } @keyframes df-enter-zoom { from { transform: scale(var(--df-motion-scale-in)); } to { transform: scale(1); } } @keyframes df-enter-zoom-out { from { transform: scale(var(--df-motion-scale-out)); } to { transform: scale(1); } } @keyframes df-enter-pop { 0% { transform: scale(0.65); } 65% { transform: scale(1.07); } 100% { transform: scale(1); } } @keyframes df-enter-spin { from { transform: rotate(var(--df-motion-spin)); } to { transform: rotate(0deg); } } @keyframes df-enter-flip { from { transform: perspective(900px) rotateY(-70deg); } to { transform: perspective(900px) rotateY(0deg); } } @keyframes df-enter-skew { from { transform: skewX(var(--df-motion-angle)); } to { transform: skewX(0deg); } } @keyframes df-enter-blur { from { filter: blur(var(--df-motion-blur)); } to { filter: blur(0); } } @keyframes df-enter-wipe { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0); } } @keyframes df-enter-wipe-up { from { clip-path: inset(100% 0 0 0); } to { clip-path: inset(0); } } @keyframes df-enter-iris { from { clip-path: circle(0% at 50% 50%); } to { clip-path: circle(150% at 50% 50%); } } @keyframes df-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } } /* accessibility: near-instant instead of none - the JS finished/cancel contract behaves identically (a finished 1ms animation still resolves), while nothing visibly moves. REQUIRED for every animation. */ @media (prefers-reduced-motion: reduce) { [data-df-entrance], [data-df-draw] { --df-motion-duration: 1ms !important; --df-motion-delay: 0ms !important; } }}Comments, ideas or improvements? Edit this page's source on GitHub