Theme
On this page (4)

Slide Down is the directional slide animation: slideIn starts the element at the named edge and settles it into place, slideOut sends it back out toward that same edge - with direction: 'north' (the default) the element starts above and slides down, matching the motion component's down entrance. All four orientation variants (north, south, east, west) ship per variant, and everything is driven by the df$.anim.slideIn.*() / df$.anim.slideOut.*() channels - play, pause, resume, finish, reset, state.

§In / out, directions and lifecycle

slideIn starts at the named edge, slideOut exits toward it. Direction buttons pick the edge (N is the default - the slide-DOWN motion); Pause/Resume/Finish/Reset act on whichever channel played last. Duration and easing apply to the next play.

§Scroll-bound slide-in

opts.scroll ties the animation's progress to a scroll position instead of time - scroll the stage and the box slides down in proportion. The platform's ScrollTimeline drives it where available; a rAF-throttled listener scrubs currentTime elsewhere.

The same binding with { target: 'viewport' } (the default when target is omitted) ties progress to the page's own scroll position instead of a container - the parallax use for presentations.

§Config axes

§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