Domphy

containerScrollAnimation

A Scroll block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call containerScrollAnimation() with no arguments for a working demo, or edit the code below live.

Implementation notes

position pinned range drives a single rAF-lerped State<number> read by reactive style.transform on the card (rotateX from a caller-tunable initialRotationDegrees down to 0, scale from initialScale up to 1) and a subtle opacity/translateY on the header, exactly matching the spec's continuous, scroll-bound (not keyframe) motion. perspective is set via themeSpacing on the sticky ancestor so the rotateX reads as real 3D depth.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Container Scroll Animation" — clean-room reimplementation
// from the public behavior/visual spec only (no upstream source viewed or
// copied). A hero section: a heading sits above a large rounded card that
// rests reclined in 3D perspective and scaled down, then "stands up" to
// face the viewer at full size as the user scrolls through the section.
//
// Same `position: sticky` pinned-range idiom `textReveal()`/`macbookScroll`
// use: a tall outer `<section>` defines the scroll room, an inner
// `position: sticky` stage stays pinned for that whole range, and progress
// (0 at pin-start, 1 at pin-release) comes from the outer section's
// `getBoundingClientRect()` against `window.innerHeight`. Unlike
// `macbookScroll`/`parallaxScroll` (which write straight to captured DOM
// refs every frame — cheaper for many repeated child nodes), this component
// only ever transforms two elements (the header and the card), so progress
// is kept as a `State<number>` and both transforms are plain reactive
// `style.transform` functions reading it — the same idiom `textReveal`/
// `googleGeminiEffect` use, letting Domphy's own reactivity do the DOM
// writes instead of an imperative paint function.
//
// `perspective` lives on the sticky stage (the rotated card's own ancestor)
// via `themeSpacing()`, not a raw px value — without an ancestor perspective
// the card's `rotateX` would read as a flat vertical squish instead of
// genuine 3D depth.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { heading, strong } from "@domphy/ui";
import { themeColor, themeSpacing } from "@domphy/theme";

export interface ContainerScrollAnimationProps {
  /** Heading rendered above the card. Defaults to a two-line demo headline with a colored accent word. */
  titleComponent?: string | DomphyElement;
  /** Content rendered inside the card (typically a screenshot/mockup image). Defaults to a placeholder image. */
  children?: DomphyElement | DomphyElement[];
  /** Card's starting `rotateX`, in degrees, at scroll progress 0 (its top edge leaning away from the
   * viewer, reclined). Defaults to `20`. */
  initialRotationDegrees?: number;
  /** Card's starting scale at scroll progress 0. Defaults to `0.75`. */
  initialScale?: number;
  /** How tall the scroll wrapper is, in viewport-height units. Defaults to `200`, clamped to a minimum of `140`. */
  wrapperHeightVh?: number;
  /** Passthrough style merged onto the card. */
  style?: StyleObject;
}

let containerScrollAnimationInstanceCounter = 0;

function clampToUnitRange(value: number): number {
  if (Number.isNaN(value)) return 0;
  return Math.min(1, Math.max(0, value));
}

/** Pinned-range progress: 0 when the section's top reaches the viewport top, 1 when its
 * bottom reaches the viewport bottom — same math `textReveal()`/`macbookScroll` use. */
function computePinnedProgress(sectionElement: HTMLElement): number {
  const rect = sectionElement.getBoundingClientRect();
  const viewportHeight = window.innerHeight;
  const scrollableDistance = rect.height - viewportHeight;
  const raw = scrollableDistance > 0 ? -rect.top / scrollableDistance : rect.top <= 0 ? 1 : 0;
  return clampToUnitRange(raw);
}

/**
 * A hero section whose large card stands up out of a reclined 3D pose into
 * a flat, full-size presentation as the section scrolls through the
 * viewport — purely scroll-driven, no click required. Call with no
 * arguments for a working demo (a two-line headline over a placeholder
 * screenshot card).
 */
function containerScrollAnimation(props: ContainerScrollAnimationProps = {}): DomphyElement<"section"> {
  const instanceId = ++containerScrollAnimationInstanceCounter;
  const initialRotationDegrees = props.initialRotationDegrees ?? 20;
  const initialScale = Math.min(1, Math.max(0.4, props.initialScale ?? 0.75));
  const wrapperHeightVh = Math.max(140, Math.round(props.wrapperHeightVh ?? 200));
  const progress = toState(0, `container-scroll-animation-${instanceId}`);

  const titleNode: DomphyElement =
    typeof props.titleComponent === "string"
      ? ({ h2: props.titleComponent, $: [heading()] } as DomphyElement)
      : (props.titleComponent ??
        ({
          h2: [
            "Build interfaces that feel ",
            { strong: "alive", $: [strong({ color: "primary" })] } as DomphyElement,
            ".",
          ],
          $: [heading()],
        } as DomphyElement));

  const cardChildren: DomphyElement[] = props.children
    ? Array.isArray(props.children)
      ? props.children
      : [props.children]
    : [
        {
          img: null,
          src: "https://picsum.photos/seed/domphy-container-scroll/1400/860",
          alt: "Product screenshot",
          _doctorDisable: "missing-color",
          style: { display: "block", width: "100%", height: "100%", objectFit: "cover" } as StyleObject,
        } as DomphyElement,
      ];

  return {
    section: [
      {
        div: [
          {
            div: [titleNode],
            style: {
              position: "relative",
              zIndex: 1,
              textAlign: "center",
              marginBlockEnd: themeSpacing(10),
              opacity: (listener: Listener) => 0.6 + 0.4 * progress.get(listener),
              transform: (listener: Listener) => `translateY(${(6 - 6 * progress.get(listener)).toFixed(1)}px)`,
            } as StyleObject,
          } as DomphyElement<"div">,
          {
            div: cardChildren,
            dataTone: "shift-16",
            style: {
              position: "relative",
              width: "min(92vw, 64em)",
              aspectRatio: "16 / 9",
              overflow: "hidden",
              borderRadius: themeSpacing(6),
              outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
              outlineOffset: "-1px",
              boxShadow: (listener: Listener) => `0 ${themeSpacing(6)} ${themeSpacing(16)} ${themeColor(listener, "shift-17")}`,
              backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
              color: (listener: Listener) => themeColor(listener, "shift-9"),
              transformOrigin: "50% 100%",
              transform: (listener: Listener) => {
                const value = progress.get(listener);
                const rotation = initialRotationDegrees * (1 - value);
                const scale = initialScale + (1 - initialScale) * value;
                return `rotateX(${rotation.toFixed(2)}deg) scale(${scale.toFixed(3)})`;
              },
              ...(props.style ?? {}),
            } as StyleObject,
          } as DomphyElement<"div">,
        ],
        style: {
          position: "sticky",
          insetBlockStart: 0,
          height: "100vh",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          perspective: themeSpacing(320),
        } as StyleObject,
      } as DomphyElement<"div">,
    ],
    style: { position: "relative", minHeight: `${wrapperHeightVh}vh` } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return;
      const sectionElement = node.domElement as HTMLElement;

      let currentProgress = computePinnedProgress(sectionElement);
      let targetProgress = currentProgress;
      let isAnimating = false;
      let animationFrameHandle = 0;
      progress.set(currentProgress);

      function step(): void {
        currentProgress += (targetProgress - currentProgress) * 0.18;
        if (Math.abs(targetProgress - currentProgress) < 0.001) {
          currentProgress = targetProgress;
          progress.set(currentProgress);
          isAnimating = false;
          return;
        }
        progress.set(currentProgress);
        animationFrameHandle = window.requestAnimationFrame(step);
      }

      function handleScroll(): void {
        targetProgress = computePinnedProgress(sectionElement);
        if (!isAnimating) {
          isAnimating = true;
          animationFrameHandle = window.requestAnimationFrame(step);
        }
      }

      window.addEventListener("scroll", handleScroll, { passive: true });
      window.addEventListener("resize", handleScroll);

      node.addHook("Remove", () => {
        window.removeEventListener("scroll", handleScroll);
        window.removeEventListener("resize", handleScroll);
        if (animationFrameHandle) window.cancelAnimationFrame(animationFrameHandle);
      });
    },
  };
}

export { containerScrollAnimation };

← Back to Aceternity UI catalog