Skip to main content

ns-ui

Slump Mould Drape

A content-reveal/settle moment built on kiln-slumping in warm glass work: a flat profile sags into a mould's contour under gravity, the centre drooping first and the drape spreading outward toward the edges over several seconds before holding, exactly conformed to the mould.

Use when Pick slump-mould-drape when a card, panel, or empty state should settle into its final shape with a visible, staggered centre-first lag rather than a uniform ease — the differentiator from a generic reveal is that the centre and edges are visibly out of sync as they conform. Pick skeleton-develop instead for a plain content-loading placeholder with no target-contour mechanic; reveal-ripple-tiles when the reveal should read as a discrete tile grid uncovering rather than a single continuous sagging surface.

Install

npx shadcn add https://design.helpmarq.com/r/slump-mould-drape.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.

Source
registry/core/slump-mould-drape/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// SlumpMouldDrape — a content-reveal/settle moment built on kiln-slumping in
// warm glass work: a flat glass blank heated to its slumping range sags
// purely under gravity over a mould, the centre drooping first (least
// supported) while the drape spreads outward toward the rim as heat and time
// progress. It's a slow, staggered viscous deformation, never a snap or a
// bounce — the whole point is the visible LAG between the centre and the
// edges, not a uniformly eased reveal.
//
// The profile is a pure function of elapsed time: N=32 sample points, each
// following y_i(t) = mould(x_i) * (1 - exp(-(t - delay_i) / TAU)), where
// delay_i grows with distance from centre (0 at centre, DELAY_EDGE_S at the
// rim). No physics state carries frame to frame, which is what makes the
// reduced-motion freeze frame a single function call rather than a replay.
// ---------------------------------------------------------------------------

const N_POINTS = 32;
const DRAPE_S = 4.5; // flat -> fully conformed
const HOLD_S = 1.5; // fully slumped, motionless, exact conformity
const RESET_S = 0.4; // lifts back to flat (a fresh blank going into the kiln)
const CYCLE_S = DRAPE_S + HOLD_S + RESET_S; // 6.4s, repeats indefinitely
const TAU_S = 1.0;
const DELAY_EDGE_S = 1.8; // delay at the outermost sample point, 0 at centre
const MAX_DROOP_FRAC = 0.3; // of the container's smaller dimension
const STATIC_TIME_S = 2.5; // reduced-motion freeze: centre conformed, edges still lagging

function mouldDepth(x: number, depth: number): number {
  // shallow-bowl reference curve: 0 at both rims, depth at centre
  return (depth * (1 - Math.cos(2 * Math.PI * x))) / 2;
}

function delayAt(x: number): number {
  return DELAY_EDGE_S * (Math.abs(x - 0.5) / 0.5);
}

// the profile's y-offset (px, downward-positive) for sample x at drape-phase
// elapsed time; only valid while phaseT < DRAPE_S.
function draping(x: number, elapsed: number, depth: number): number {
  const delay = delayAt(x);
  if (elapsed <= delay) return 0;
  return mouldDepth(x, depth) * (1 - Math.exp(-(elapsed - delay) / TAU_S));
}

export interface SlumpMouldDrapeProps {
  /** ambient idle loop (drape -> hold -> reset -> repeat) vs. a one-shot
   * reveal that plays once and holds its settled state forever. @default true */
  loop?: boolean;
  className?: string;
}

export function SlumpMouldDrape({
  loop = true,
  className = "",
}: SlumpMouldDrapeProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    // -- token-derived ink, read at mount and re-derived on theme flip ------
    let fg = "rgb(237,237,237)";
    let border = "rgba(237,237,237,0.11)";
    let dark = true;
    const readTokens = () => {
      dark = document.documentElement.classList.contains("dark");
      const cs = getComputedStyle(document.documentElement);
      fg = cs.getPropertyValue("--foreground").trim() || fg;
      border = cs.getPropertyValue("--border").trim() || border;
    };
    readTokens();

    // -- hot-path state: locals only, never React state ---------------------
    let w = 0;
    let h = 0;
    let dpr = 1;
    let sized = false;
    let paused = false;
    let reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let baselineY = 0;
    let depth = 0;
    let lineWidth = 1.4;
    let mouldLineWidth = 1;

    const xAt = (i: number) => i / (N_POINTS - 1);

    // profile y-offset (downward-positive, px) for sample index i at a given
    // absolute elapsed time within one cycle (or clamped past the hold end
    // when !loop).
    const profileOffset = (i: number, phaseT: number) => {
      const x = xAt(i);
      if (phaseT < DRAPE_S) return draping(x, phaseT, depth);
      if (phaseT < DRAPE_S + HOLD_S) return mouldDepth(x, depth); // exact conformity
      const resetT = phaseT - DRAPE_S - HOLD_S;
      const frac = Math.min(1, resetT / RESET_S);
      const eased = frac * frac * (3 - 2 * frac); // smoothstep lift
      return mouldDepth(x, depth) * (1 - eased);
    };

    const drawPolyline = (getY: (i: number) => number, useCurve: boolean) => {
      ctx.beginPath();
      for (let i = 0; i < N_POINTS; i++) {
        const px = (xAt(i) * w);
        const py = getY(i);
        if (i === 0) {
          ctx.moveTo(px, py);
        } else if (useCurve) {
          const prevX = xAt(i - 1) * w;
          const prevY = getY(i - 1);
          const midX = (prevX + px) / 2;
          const midY = (prevY + py) / 2;
          ctx.quadraticCurveTo(prevX, prevY, midX, midY);
        } else {
          ctx.lineTo(px, py);
        }
      }
      // finish the last curve segment to the final point
      if (useCurve) {
        ctx.lineTo(xAt(N_POINTS - 1) * w, getY(N_POINTS - 1));
      }
      ctx.stroke();
    };

    const draw = (phaseT: number) => {
      if (!sized) return;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);

      // mould reference contour — a fixed, non-load-bearing target line the
      // profile drapes toward; --border only, never a fill or the "arrived"
      // signal itself (that's read from geometric conformity, not colour).
      ctx.strokeStyle = border;
      ctx.lineWidth = mouldLineWidth;
      ctx.globalAlpha = 1;
      drawPolyline((i) => baselineY + mouldDepth(xAt(i), depth), true);

      // glass profile
      ctx.strokeStyle = fg;
      ctx.lineWidth = lineWidth;
      ctx.lineCap = "round";
      ctx.lineJoin = "round";
      drawPolyline((i) => baselineY + profileOffset(i, phaseT), true);
    };

    const resize = () => {
      const rect = root.getBoundingClientRect();
      w = rect.width;
      h = rect.height;
      if (w < 2 || h < 2) {
        sized = false;
        return;
      }
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      const minDim = Math.min(w, h);
      depth = MAX_DROOP_FRAC * minDim;
      baselineY = h * 0.5 - depth * 0.5;
      lineWidth = Math.max(1.4, 0.007 * minDim);
      // light theme's --border reads at ~1.1:1 — a heavier stroke (same
      // token, not a different one) keeps the mould contour legible as a
      // target line rather than a different-weight rule change per theme.
      mouldLineWidth = Math.max(1, 0.0045 * minDim) * (dark ? 1 : 1.6);
      sized = true;
    };

    const renderStatic = () => {
      draw(STATIC_TIME_S);
    };

    let raf = 0;
    let start = 0;
    const loopFrame = (now: number) => {
      if (!start) start = now;
      const elapsed = (now - start) / 1000;
      const phaseT = loop
        ? elapsed % CYCLE_S
        : Math.min(elapsed, DRAPE_S + HOLD_S);
      draw(phaseT);
      if (!paused) raf = requestAnimationFrame(loopFrame);
    };

    const stopLoop = () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    const startLoop = () => {
      stopLoop();
      if (paused || !sized) return;
      if (reduced) {
        renderStatic();
        return;
      }
      start = 0;
      raf = requestAnimationFrame(loopFrame);
    };

    resize();

    const ro = new ResizeObserver(() => {
      resize();
      if (reduced) renderStatic();
    });
    ro.observe(root);

    const onThemeChange = () => {
      readTokens();
      resize(); // mould stroke weight depends on the dark/light read above
      if (reduced) draw(STATIC_TIME_S);
    };
    const mo = new MutationObserver(onThemeChange);
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "data-theme"],
    });
    const colorScheme = window.matchMedia("(prefers-color-scheme: dark)");
    colorScheme.addEventListener("change", onThemeChange);

    const reducedMq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onReducedChange = () => {
      reduced = reducedMq.matches;
      startLoop();
    };
    reducedMq.addEventListener("change", onReducedChange);

    const onVisibility = () => {
      paused = document.hidden;
      startLoop();
    };
    document.addEventListener("visibilitychange", onVisibility);

    let io: IntersectionObserver | undefined;
    if ("IntersectionObserver" in window) {
      io = new IntersectionObserver((entries) => {
        const entry = entries[0];
        if (!entry) return;
        paused = !entry.isIntersecting || document.hidden;
        startLoop();
      });
      io.observe(root);
    }

    startLoop();

    return () => {
      stopLoop();
      ro.disconnect();
      mo.disconnect();
      colorScheme.removeEventListener("change", onThemeChange);
      reducedMq.removeEventListener("change", onReducedChange);
      document.removeEventListener("visibilitychange", onVisibility);
      io?.disconnect();
    };
  }, [loop]);

  return (
    <div
      ref={rootRef}
      className={`relative h-full w-full overflow-hidden ${className}`}
    >
      <canvas ref={canvasRef} aria-hidden className="block h-full w-full" />
    </div>
  );
}
Build spec

Build <SlumpMouldDrape loop? className?> as a card-scale 2D-canvas content-reveal mechanic modelled on kiln-slumping: a flat glass profile sags under gravity into a mould's contour, centre first, edges last. THE PROFILE IS A PURE FUNCTION OF TIME: N_POINTS=32 samples span the container width; each sample's y-offset is y_i(t) = mould(x_i) * (1 - exp(-(t - delay_i)/TAU_S)) for t in the DRAPE_S=4.5s drape phase, where mould(x) = depth * (1 - cos(2*pi*x)) / 2 (a shallow bowl, 0 at both rims, depth=MAX_DROOP_FRAC=0.3 * containerMinDim at centre), delay_i = DELAY_EDGE_S=1.8s * (|x_i - 0.5| / 0.5) (0 at centre, 1.8s at the rim), and TAU_S=1.0. Nothing here carries state frame-to-frame — the whole animation is draw(phaseT) called with whatever phaseT the clock produces, which is exactly why the reduced-motion freeze frame is a single function call at STATIC_TIME_S=2.5 rather than a replay loop. PHASES: 0..DRAPE_S is the staggered drape above; DRAPE_S..DRAPE_S+HOLD_S=1.5s snaps every sample to mould(x_i) EXACTLY (full conformity, motionless — the asymptotic exp() value never quite reaches 1, so this snap is what makes the hold read as "matches the mould" rather than "almost there"); DRAPE_S+HOLD_S..CYCLE_S=6.4s (only when loop=true) lifts every sample back toward 0 over RESET_S=0.4s with a smoothstep ease (frac*frac*(3-2*frac)) — fast relative to the drape, since a real reset is a discrete offstage event (swapping blanks), not a slow process. loop=true (default) repeats the cycle indefinitely for the ambient/idle use; loop=false clamps phaseT at DRAPE_S+HOLD_S forever once reached, for a one-shot reveal (e.g. content finishing loading) whose settled state must persist rather than looping into a reset. RENDER: both the mould reference contour (drawn first, fixed, never animated) and the glass profile are stroked as a smoothed polyline — quadraticCurveTo through each pair of sample points' midpoint — never raw per-point dots, so the sheet reads as one continuous surface. TOKENS: profile line is --foreground; mould contour is --border only, and specifically NEVER the "arrived" signal (that reads from the profile's geometric conformity to the mould line, not colour) and never --ns-accent. Because --border is ~1.1:1 contrast in light theme, the mould's stroke WEIGHT (not a different token) is multiplied by 1.6x when documentElement lacks the dark class, so the target line the profile drapes toward stays legible in the harder theme. HOST: ResizeObserver on the root recomputes width/height/dpr(capped 2)/depth/baseline/stroke-weights (and the dark/light read they depend on) — never window.resize; IntersectionObserver(threshold 0) and visibilitychange both pause the single rAF loop; unmount cancels the frame and disconnects every observer/listener. Tokens are read via getComputedStyle(document.documentElement) plus documentElement.classList.contains('dark') at mount, before any paint, and re-read on a MutationObserver watching class/data-theme plus a prefers-color-scheme change listener. prefers-reduced-motion (matchMedia, live via a change listener) replaces the rAF loop with one draw(STATIC_TIME_S) call — centre points ~92% conformed, outer points ~50% conformed, the frame that shows the mechanic's signature centre-first/edges-last asymmetry that neither the flat start nor the fully-settled hold can show alone.

Props

PropTypeDefaultDescription
loop?booleantrueambient idle loop (drape -> hold -> reset -> repeat) vs. a one-shot reveal that plays once and holds its settled state forever. @default true
className?string