defuss-shadcn / Guides / Animations / anim-zoom
ZoomATM
The scale entrance/exit pair of the imperative animation engine: zoomIn grows the element from scale(0.8) (configurable) and transparent to full size, zoomOut shrinks it away again - both around a configurable transform-origin. Zoom is not directional; its geometry axis is the origin point. Everything below is driven by df$.anim.zoomIn and df$.anim.zoomOut - Web Animations API under the hood, no CSS class toggling.
On this page (4)
§In / out, origin and lifecycle
In plays zoomIn, Out plays zoomOut - Pause/Resume/Finish/Reset act on whichever channel ran last. Origin moves the transform-origin the scale grows from; duration and easing are per-play options.
§Scroll binding
opts.scroll ties the animation's progress to a scroll position instead of time - here the box zooms in as you scroll the stage. reset() unwinds the binding.
Passing scroll: { target: 'viewport' } instead of an element binds progress to the page's own scroll - the parallax use for presentations and long-form pages.
§Config axes
origin- thetransform-originthe scale grows from / shrinks toward, e.g.'50% 50%'(default) or'0% 0%'.scale- the scale factor at the hidden end (default0.8; a value above 1, e.g.1.1, turns the pair into a shrink-in / grow-out effect).duration- total duration in ms (default 1500; 1ms underprefers-reduced-motion).easing- any CSS easing (defaultcubic-bezier(0.16, 1, 0.3, 1)).
§CSS view file
The shared motion stylesheet - the declarative data-df-entrance twin of this page's df$.anim channels: the --df-motion-… tunables, the fifteen df-enter-… keyframes 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