Theme
On this page (4)

Flip is the rotational reveal: the element tips through 70° on a shared 900px perspective while cross-fading, so it reads as a card turning over rather than a plain fade. It ships as the paired channels flipIn / flipOut, each directional: north and south flip around rotateX (a horizontal hinge), west and east around rotateY (a vertical hinge). In-variants start tipped toward the named edge, out-variants exit toward it. Everything below is driven by df$.anim.flipIn / df$.anim.flipOut - play, pause, resume, finish, reset.

§In / out, directions and lifecycle

flipIn starts tipped toward the selected edge, flipOut exits toward it. Pause/Resume/Finish/Reset act on whichever channel ran last - Reset unwinds the fill state entirely.

§Scroll-bound flip

The scroller's progress drives flipIn: at the top the card is tipped away, at the bottom it rests flat. ScrollTimeline where the platform has it, a rAF-throttled scrub elsewhere.

Passing { target: 'viewport' } (the default) instead of an element binds progress to the page scroll itself - the parallax use for presentations, where a flip unfolds as its section scrolls into place.

§Configuration

§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