defuss-shadcn / Guides / Animations / anim-iris
IrisATM
On this page (4)
The iris is a clip-path: circle() reveal - the visible region grows from a point into a full circle (irisIn) or collapses back into a point (irisOut). It is not directional; instead the origin is the star config: the circle opens and closes around any 'x y' position you pass (default '50% 50%'). Everything below is driven by the imperative engine - df$.anim.irisIn.*() and df$.anim.irisOut.*() - whose generated keyframes make origin, duration and easing true runtime config.
§In / out, origins and lifecycle
irisIn opens a circle at the chosen origin, irisOut closes toward it. Pick an origin, tune duration and easing, then drive the full lifecycle - Pause, Resume, Finish and Reset act on the last-used channel.
§Scroll-bound iris
The same channel, bound to scroll instead of time: scrolling the stage scrubs the iris open - the parallax use. Reset re-binds it.
Press Bind to scroll, then scroll the stage: the scroll position scrubs the iris open. Passing { target: 'viewport' } instead of an element binds the same progress to page scroll - the parallax use for presentations.
§Config axes
origin-'x y'position the circle opens from / closes toward (default'50% 50%'; demo: center, top-left0% 0%, bottom-right100% 100%).duration- total time in ms (default1500).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