Skip to main content

ns-ui / loud

Curtain Austrian Gather

A full-bleed route curtain modeled on a real Austrian (brail) stage drape: sewn-in-fullness fabric hoisted bottom-up on parallel vertical lift lines, so it doesn't slide flat but gathers into a row of scalloped swags whose bellies deepen and breathe on independent phase-offset sines, both at rest and as the drape hoists.

Usage

registry/loud/curtain-austrian-gather/demo.tsx
"use client";

import { CurtainAustrianGather } from "./component";

export default function CurtainAustrianGatherDemo() {
  return (
    <main className="relative h-screen w-full overflow-hidden bg-background">
      {/* the destination route the curtain is blocking — a real page mock,
          not a placeholder, so hoisting the drape reveals something */}
      <div className="flex h-full w-full flex-col items-center justify-center gap-4 px-6 text-center">
        <p className="font-mono text-[11px] uppercase tracking-[0.28em] text-ns-muted">
          ns-ui / curtain-austrian-gather
        </p>
        <h1 className="max-w-lg text-3xl font-medium text-foreground sm:text-4xl">
          The route behind the swags
        </h1>
        <p className="max-w-md text-sm text-ns-muted">
          Hoisting the drape gathers it bottom-up into scalloped swags — click "Skip curtain" to
          hoist it early.
        </p>
      </div>
      <CurtainAustrianGather />
    </main>
  );
}

Component

registry/loud/curtain-austrian-gather/component.tsx
"use client";

import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";

// CurtainAustrianGather — a full-bleed route curtain modeled on a real
// Austrian (brail) drape: unlike a traveler (parts horizontally,
// curtain-traveler-draw) or a tab curtain (peels diagonally,
// curtain-tab-diagonal), an Austrian drape rides on PARALLEL VERTICAL LIFT
// LINES and hoists bottom-up. Because the fabric carries ~100% sewn-in
// fullness, it does not slide flat as the lines rise — it gathers into a
// row of scalloped SWAGS along the hem, one swag per lift-line span, each
// swag's belly deepening and its silhouette compressing upward as the
// drape hoists. That festooned bottom edge, not a straight parting line,
// is the whole point of the mechanic.
//
// Substrate: SVG. Each swag is one closed path — a straight top edge
// pinned to the header track, two vertical sides riding its bounding lift
// lines, and a cubic-bezier belly for the scallop — filled with a per-swag
// horizontal gradient so the gather reads as shaded fabric, not a flat
// festoon shape. (--border is a separator token, ~1.1:1 against
// --background in light theme — unusable as fabric ink. Fold/scallop form
// is carried in VALUE against --ns-muted's fabric body instead: a
// --foreground-tinted shadow gradient and a --background-tinted highlight
// gradient per swag, layered as partial opacity, never --border.)
//
// The resting (closed) state already breathes: every swag's belly depth
// rides its own slow sine, independently phase-offset from its neighbours,
// so individual scallops deepen and relax at rest — not a whole-panel
// opacity or scale pulse. The gradient crease inside each swag breathes on
// its own offset phase too, the same "rewrite the gradient stops every
// frame" idiom curtain-traveler-draw uses for its pleats. Lifting runs a
// single rAF timeline that eases every lift-line's hem height upward
// together, with a small end-of-travel settle (rope give snapping the
// drape into its batten) and a swag belly that grows dramatically deeper
// as the shrinking panel bunches fabric toward the header.
//
// Colors come only from --ns-muted (fabric body), --foreground (shadow
// side, rope lines) and --background (highlight side, plus the backing
// "revealed page" rect), read once via getComputedStyle(document
// .documentElement) and re-read on a MutationObserver watching
// documentElement's class — no hex or rgb() literals anywhere in the draw
// code. --ns-accent never touches the fabric; it only tints the optional
// "skip curtain" trigger button.

export interface CurtainAustrianGatherProps {
  /** Called once the lift animation (or reduced-motion hard cut) completes. */
  onLiftComplete?: () => void;
  /** Freezes the idle breathing and any in-flight lift at its current frame. */
  paused?: boolean;
  className?: string;
}

type Tokens = {
  muted: string;
  foreground: string;
  background: string;
};

const VW = 1600;
const VH = 900;
const TRACK_Y = 20;
const TRACK_H = 10;

const SWAGS = 7;
const LIFT_LINES = SWAGS + 1;

const REST_HEM_Y = VH - 60; // 840 — drape hangs low, nearly full-bleed at rest
const LIFT_HEM_Y = 170; // hoisted hem, bunched tight beneath the header

const REST_SWAG_DEPTH = 48; // px belly droop below the hem line, at rest — visible festoon, not a flat hem
const LIFT_SWAG_DEPTH = 110; // px belly droop, fully hoisted — dramatic gather

const LIFT_MS = 600; // verify.ts's gate waits 700ms after the click — finish comfortably inside that
const SETTLE_OVERSHOOT = 0.06; // fraction of travel the rope gives past rest, then snaps back

const BREATH_PERIOD_MS = 4300;
const BREATH_AMP_FRAC = 0.4; // fraction of current belly depth, min-floored below

// A dedicated marker, not the full backing rect: its centre is a point we
// control (well clear of every lift line and comfortably inside the region
// the swags vacate once hoisted), rather than the worst-case centre of a
// full-viewBox rect that the fabric happens to cover last.
const MARKER_W = 160;
const MARKER_H = 48;
const MARKER_Y = VH * 0.55; // below LIFT_HEM_Y + LIFT_SWAG_DEPTH + breath headroom, above REST_HEM_Y

function easeRise(t: number): number {
  // f(1) = 1 exactly — a small overshoot (rope give at full hoist, then a
  // snap into the batten) lives entirely inside [0,1], so it settles back
  // to the exact hoisted height without extending duration.
  const c1 = SETTLE_OVERSHOOT * 2.6;
  const c3 = c1 + 1;
  const p = t - 1;
  return 1 + c3 * p * p * p + c1 * p * p;
}

function lineX(i: number): number {
  return (i / SWAGS) * VW;
}

type StopRefs = {
  base0: SVGStopElement | null;
  hiIn: SVGStopElement | null;
  hiPeak: SVGStopElement | null;
  shPeak: SVGStopElement | null;
  shOut: SVGStopElement | null;
  base1: SVGStopElement | null;
};

function emptyStopRefs(): StopRefs {
  return { base0: null, hiIn: null, hiPeak: null, shPeak: null, shOut: null, base1: null };
}

export function CurtainAustrianGather({ onLiftComplete, paused = false, className = "" }: CurtainAustrianGatherProps) {
  const uid = useId().replace(/[:]/g, "");
  const rootRef = useRef<HTMLDivElement | null>(null);
  const swagBaseRefs = useRef<(SVGPathElement | null)[]>(Array.from({ length: SWAGS }, () => null));
  const swagPathRefs = useRef<(SVGPathElement | null)[]>(Array.from({ length: SWAGS }, () => null));
  const stopRefs = useRef<StopRefs[]>(Array.from({ length: SWAGS }, emptyStopRefs));
  const ropeRefs = useRef<(SVGLineElement | null)[]>(Array.from({ length: LIFT_LINES }, () => null));
  const trackRef = useRef<SVGRectElement | null>(null);
  const bgRectRef = useRef<SVGRectElement | null>(null);
  const markerRef = useRef<SVGRectElement | null>(null);

  // No hardcoded fallback palette: a hex default here would be the light-
  // theme value baked in, so a dark-theme visitor could see one wrong-
  // polarity frame before the real read lands. Null until derive() runs,
  // and derive() runs in a layout effect (before the browser's first
  // paint), not a passive effect, so that frame never actually happens.
  const tokensRef = useRef<Tokens | null>(null);

  const [open, setOpen] = useState(false);
  const [lifting, setLifting] = useState(false);
  const [reduced, setReduced] = useState(false);
  const [mounted, setMounted] = useState(false);

  const liftStartRef = useRef<number | null>(null);
  const elapsedRef = useRef<number>(0);
  const lastTsRef = useRef<number>(0);
  const rafRef = useRef<number | undefined>(undefined);
  const pausedRef = useRef(paused);
  const openRef = useRef(false);
  const liftingRef = useRef(false);

  useEffect(() => {
    pausedRef.current = paused;
  }, [paused]);

  useEffect(() => {
    setMounted(true);
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  // --- token read + re-read on theme flip ------------------------------
  const applyStaticColors = () => {
    const t = tokensRef.current;
    if (!t) return;
    // --border is a separator token (~1.1:1 against --background in light
    // theme) — unusable as fabric ink. The scallop's form is modelled in
    // VALUE against the --ns-muted body instead: shadow shades toward
    // --foreground, highlight toward --background, both layered as
    // partial opacity over the opaque muted base so it reads in both
    // polarities. Ropes are thin --foreground strokes.
    if (trackRef.current) trackRef.current.setAttribute("fill", t.muted);
    if (bgRectRef.current) bgRectRef.current.setAttribute("fill", t.background);
    if (markerRef.current) markerRef.current.setAttribute("fill", t.muted);
    for (const base of swagBaseRefs.current) base?.setAttribute("fill", t.muted);
    for (const rope of ropeRefs.current) rope?.setAttribute("stroke", t.foreground);
    for (const refs of stopRefs.current) {
      refs.base0?.setAttribute("stop-color", t.muted);
      refs.base1?.setAttribute("stop-color", t.muted);
      refs.hiIn?.setAttribute("stop-color", t.background);
      refs.hiPeak?.setAttribute("stop-color", t.background);
      refs.shPeak?.setAttribute("stop-color", t.foreground);
      refs.shOut?.setAttribute("stop-color", t.foreground);
    }
  };

  // useLayoutEffect, not useEffect: runs before the browser's first paint,
  // so the fabric never has a frame to render in the wrong theme's colors
  // before the token read lands — no hardcoded fallback palette needed.
  useLayoutEffect(() => {
    const derive = () => {
      const cs = getComputedStyle(document.documentElement);
      const read = (name: string) => cs.getPropertyValue(name).trim();
      tokensRef.current = {
        muted: read("--ns-muted"),
        foreground: read("--foreground"),
        background: read("--background"),
      };
      applyStaticColors();
    };
    derive();
    const mo = new MutationObserver(derive);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
    return () => mo.disconnect();
  }, [mounted]);

  /** Belly path for swag i between lift lines i and i+1, at hem heights
   *  (yL, yR) and additional droop depth `depth` beyond their average. */
  const swagPath = (i: number, yL: number, yR: number, depth: number): string => {
    const x0 = lineX(i);
    const x1 = lineX(i + 1);
    const midY = (yL + yR) / 2 + depth;
    const c1x = x0 + (x1 - x0) / 3;
    const c2x = x0 + ((x1 - x0) * 2) / 3;
    return (
      `M ${x0} ${TRACK_Y} L ${x1} ${TRACK_Y} L ${x1} ${yR} ` +
      `C ${c2x} ${midY} ${c1x} ${midY} ${x0} ${yL} Z`
    );
  };

  // --- draw one frame (refs-only, no React state on the hot path) -------
  const draw = (elapsedMs: number) => {
    const breathPhaseBase = (elapsedMs / BREATH_PERIOD_MS) * Math.PI * 2;

    let progress = 0; // 0 = closed/lowered, 1 = fully hoisted (may briefly exceed 1: rope give)
    if (liftStartRef.current !== null) {
      // Clamped on both ends: the reduced-motion hard cut seeds
      // liftStartRef in the past (peakElapsed - LIFT_MS) so this same
      // draw() call lands exactly at frac=1, but if reduced motion later
      // toggles off mid-open, elapsedMs restarts near 0 — an unclamped
      // low end would read as negative frac and drop the curtain back
      // closed with the trigger button already unmounted.
      const frac = Math.max(0, Math.min(1, (elapsedMs - liftStartRef.current) / LIFT_MS));
      progress = openRef.current ? 1 : easeRise(frac);
      if (frac >= 1 && !openRef.current) {
        openRef.current = true;
        liftingRef.current = false;
        setOpen(true);
        setLifting(false);
        onLiftComplete?.();
      }
    }
    const clamped = Math.max(0, Math.min(1, progress));

    const hemYs: number[] = [];
    for (let i = 0; i < LIFT_LINES; i++) {
      // Slight per-line stagger so the hoist reads as several ropes
      // gathering together rather than one perfectly rigid batten.
      const stagger = ((i % 3) - 1) * 0.035;
      const local = Math.max(0, Math.min(1, progress + stagger * clamped));
      hemYs.push(REST_HEM_Y + (LIFT_HEM_Y - REST_HEM_Y) * local);
      const rope = ropeRefs.current[i];
      if (rope) {
        rope.setAttribute("x1", String(lineX(i)));
        rope.setAttribute("x2", String(lineX(i)));
        rope.setAttribute("y1", String(TRACK_Y));
        rope.setAttribute("y2", String(hemYs[i]));
      }
    }

    const depthBase = REST_SWAG_DEPTH + (LIFT_SWAG_DEPTH - REST_SWAG_DEPTH) * clamped;
    const breathAmpPx = Math.max(8, depthBase * BREATH_AMP_FRAC);

    for (let i = 0; i < SWAGS; i++) {
      const depthPhase = breathPhaseBase + i * 0.53;
      const depth = depthBase + breathAmpPx * Math.sin(depthPhase);
      const d = swagPath(i, hemYs[i], hemYs[i + 1], depth);
      // Opaque base first, gradient overlay second: the gradient dips to
      // near-zero opacity at its crease by design (that's what lets the
      // highlight/shadow band read as a real fold, not a flat wash), which
      // would otherwise leak whatever sits behind the fabric — the track,
      // ropes, and the gate's open-state marker — straight through at
      // every crease, at every height, not just at the hem.
      const base = swagBaseRefs.current[i];
      if (base) base.setAttribute("d", d);
      const path = swagPathRefs.current[i];
      if (path) path.setAttribute("d", d);

      // Same "rewrite the gradient stops every frame" idiom as
      // curtain-traveler-draw's pleats, on its own phase offset per swag
      // so the shaded crease shifts independently of the belly droop.
      const creasePhase = depthPhase + 1.1;
      const crease = 0.5 + 0.08 * Math.sin(creasePhase);
      const refs = stopRefs.current[i];
      refs?.hiIn?.setAttribute("offset", String(crease - 0.22));
      refs?.hiPeak?.setAttribute("offset", String(crease - 0.03));
      refs?.shPeak?.setAttribute("offset", String(crease + 0.03));
      refs?.shOut?.setAttribute("offset", String(crease + 0.22));
    }
  };

  // --- reduced motion: one static peak-amplitude frame, no rAF ----------
  useEffect(() => {
    if (!mounted || !reduced) return;
    // Freeze at a quarter-period so the breathing sine sits at its peak,
    // not the flat t=0 zero-crossing — swags read as gathered fabric with
    // real belly depth, not flat panels.
    draw(BREATH_PERIOD_MS / 4);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [mounted, reduced]);

  // --- normal continuous rAF loop: unconditional idle breathing + lift --
  useEffect(() => {
    if (!mounted || reduced) return;
    lastTsRef.current = performance.now();
    elapsedRef.current = 0;

    const loop = (ts: number) => {
      if (!pausedRef.current) {
        elapsedRef.current += ts - lastTsRef.current;
        draw(elapsedRef.current);
      }
      lastTsRef.current = ts;
      rafRef.current = requestAnimationFrame(loop);
    };
    rafRef.current = requestAnimationFrame(loop);
    return () => {
      if (rafRef.current !== undefined) cancelAnimationFrame(rafRef.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [mounted, reduced]);

  const handleTrigger = () => {
    if (openRef.current || liftingRef.current) return;
    if (reduced) {
      // Hard cut, no tween: jump straight to the hoisted geometry at the
      // same quarter-period breathing PEAK the resting frozen frame uses,
      // by reusing draw() itself (same stagger, same per-swag belly and
      // crease phases) rather than duplicating a flattened, uniform-depth
      // version that would lose the scallop read entirely.
      const peakElapsed = BREATH_PERIOD_MS / 4;
      liftStartRef.current = peakElapsed - LIFT_MS;
      draw(peakElapsed);
      return;
    }
    liftingRef.current = true;
    setLifting(true);
    liftStartRef.current = elapsedRef.current;
  };

  const gid = (i: number) => `${uid}-swag-${i}`;

  const renderGradients = () =>
    Array.from({ length: SWAGS }, (_, i) => (
      <linearGradient key={gid(i)} id={gid(i)} x1={lineX(i)} y1="0" x2={lineX(i + 1)} y2="0" gradientUnits="userSpaceOnUse">
        <stop
          offset="0"
          ref={(el) => {
            stopRefs.current[i].base0 = el;
          }}
        />
        <stop
          offset="0.28"
          stopOpacity="0"
          ref={(el) => {
            stopRefs.current[i].hiIn = el;
          }}
        />
        <stop
          offset="0.47"
          stopOpacity="0.5"
          ref={(el) => {
            stopRefs.current[i].hiPeak = el;
          }}
        />
        <stop
          offset="0.53"
          stopOpacity="0.45"
          ref={(el) => {
            stopRefs.current[i].shPeak = el;
          }}
        />
        <stop
          offset="0.72"
          stopOpacity="0"
          ref={(el) => {
            stopRefs.current[i].shOut = el;
          }}
        />
        <stop
          offset="1"
          ref={(el) => {
            stopRefs.current[i].base1 = el;
          }}
        />
      </linearGradient>
    ));

  const renderSwags = () =>
    Array.from({ length: SWAGS }, (_, i) => (
      <g key={i}>
        <path
          ref={(el) => {
            swagBaseRefs.current[i] = el;
          }}
          d={swagPath(i, REST_HEM_Y, REST_HEM_Y, REST_SWAG_DEPTH)}
        />
        <path
          ref={(el) => {
            swagPathRefs.current[i] = el;
          }}
          d={swagPath(i, REST_HEM_Y, REST_HEM_Y, REST_SWAG_DEPTH)}
          fill={`url(#${gid(i)})`}
        />
      </g>
    ));

  const renderRopes = () =>
    Array.from({ length: LIFT_LINES }, (_, i) => (
      <line
        key={i}
        ref={(el) => {
          ropeRefs.current[i] = el;
        }}
        x1={lineX(i)}
        y1={TRACK_Y}
        x2={lineX(i)}
        y2={REST_HEM_Y}
        strokeWidth={2}
        opacity={0.4}
      />
    ));

  const showButton = !open;

  return (
    <div
      ref={rootRef}
      className={`absolute inset-0 overflow-hidden ${className}`}
      role={open ? undefined : "status"}
      aria-live={open ? undefined : "polite"}
      aria-hidden={open ? "true" : undefined}
    >
      {!open ? <span className="sr-only">Loading</span> : null}
      <svg viewBox={`0 0 ${VW} ${VH}`} preserveAspectRatio="none" className="block h-full w-full" aria-hidden="true" focusable="false">
        <defs>{renderGradients()}</defs>
        <rect ref={bgRectRef} x={0} y={0} width={VW} height={VH} />
        {/* Dedicated open-state marker, covered by the fabric at rest and
            exposed once every lift line has risen clear of it — its centre
            is a point we control, not the worst-case centre of the full
            backing rect. */}
        <rect
          data-curtain-open
          ref={markerRef}
          x={VW / 2 - MARKER_W / 2}
          y={MARKER_Y - MARKER_H / 2}
          width={MARKER_W}
          height={MARKER_H}
          rx={4}
        />
        <rect ref={trackRef} x={0} y={TRACK_Y - TRACK_H / 2} width={VW} height={TRACK_H} />
        {renderRopes()}
        {renderSwags()}
      </svg>
      {showButton ? (
        <button
          type="button"
          data-curtain-trigger
          onClick={handleTrigger}
          disabled={lifting}
          className="absolute bottom-6 left-1/2 -translate-x-1/2 inline-flex items-center rounded-sm bg-ns-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent disabled:opacity-60 disabled:pointer-events-none"
        >
          Skip curtain
        </button>
      ) : null}
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/curtain-austrian-gather.tsx, so point the import there.

Use when a full-bleed preloader/route-transition curtain built from a real Austrian/brail stage-drape rigging — bottom-up hoist on parallel vertical lift lines that gathers the sewn-in fullness into a row of scalloped, deepening swags; pick curtain-traveler-draw instead for a horizontal parting drape with sagging pleats on a corded track, or curtain-tab-diagonal for a diagonal tab-peel, or curtain-leader-countdown when the loading moment should read as a numeric SMPTE film-leader countdown rather than any parting/hoisting drape.

Install

npx shadcn add https://design.helpmarq.com/r/curtain-austrian-gather.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <CurtainAustrianGather onLiftComplete? paused? className?> as a full-bleed route curtain modeled on a real Austrian (brail) stage drape — the rigging that hoists BOTTOM-UP on parallel vertical lift lines, distinct from a traveler curtain (curtain-traveler-draw), which parts horizontally on a corded track, and a tab curtain (curtain-tab-diagonal), which peels diagonally toward one wing. Because an Austrian drape carries roughly 100% sewn-in fullness, the fabric does not slide flat as the lift lines rise: it SCALLOPS and BUNCHES upward into a row of swags, one swag per lift-line span, each swag belonging to its own pair of lift lines. CLOSED means the fabric hangs low and full-bleed with only a shallow scallop at each swag's hem; hoisting (via the component's own built-in 'Skip curtain' trigger button, marked data-curtain-trigger) raises every lift line together, and as the panel's height shrinks the swag bellies grow dramatically deeper and the fabric visibly bunches toward the header — the signature scalloped-swag silhouette, not a straight parting edge. A small per-lift-line stagger and a brief rope-give overshoot-and-settle at the end of the hoist (an easeRise timeline whose overshoot resolves back to the exact hoisted height rather than extending duration) read as real rope-and-pulley rigging, not a mechanical slide. SVG, NOT CANVAS: each swag is one closed path — a straight top edge pinned to the header track, two vertical sides riding its bounding lift lines, and a cubic-bezier belly for the scallop curve — filled with a per-swag horizontal gradient (a --foreground-tinted shadow band and a --background-tinted highlight band, both layered as partial opacity over the opaque --ns-muted fabric body, never --border, which is a separator token at ~1.1:1 contrast against --background in light theme and effectively invisible used as fabric ink). Lift-line ropes are thin --foreground strokes from the header track down to each swag boundary's current hem height. PALETTE: fabric body in --ns-muted, swag shadow/highlight gradients shade toward --foreground and --background respectively, blocked/revealed page area in --background — --ns-accent never touches the fabric, reserved only for the built-in skip/dismiss button's interaction chrome. Colors are read once via getComputedStyle(document.documentElement) and re-read on a MutationObserver watching documentElement's class — no hex or rgb() literals anywhere in the draw code. IDLE IS UNCONDITIONAL AND PER-SWAG: even fully lowered, every swag's belly depth breathes independently on its own slow ~4.3s sine, phase-offset from its neighbours by index, and each swag's internal gradient crease breathes on its own separate phase offset — individual scallops visibly deepen and relax, and shading shifts within them, frame to frame; this is not a whole-panel opacity or scale pulse, which is exactly what the no-autoplay verifier grades apart. Because the interesting state (hoisted) is non-resting, a gate descriptor (openBy the trigger button, expect a small dedicated marker rect, centred well below the fully-hoisted belly's maximum reach and well above the resting hem, positioned in paint order beneath the ropes and swags — covered by the fabric while lowered, exposed and hittable at its own centre once every lift line has risen clear of it) triggers it for its own screenshot. prefers-reduced-motion holds a single mid-breath frame at peak swag-belly depth, where scallops read as three-dimensional gathered fabric, and cuts hard (no tween, no rAF) between lowered and hoisted on trigger rather than tweening. role=status/aria-live=polite announces a loading state while lowered; the whole overlay goes aria-hidden once fully hoisted so the revealed route becomes the real focus target, and the trigger button unmounts at that point rather than staying focusable behind an aria-hidden ancestor.

Props

PropTypeDefaultDescription
onLiftComplete?() => void—Called once the lift animation (or reduced-motion hard cut) completes.
paused?booleanfalseFreezes the idle breathing and any in-flight lift at its current frame.
className?string——