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.

Usage

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

import { SlumpMouldDrape } from "./component";

export default function SlumpMouldDrapeDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-background px-6">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / slump-mould-drape
      </p>

      <div className="w-full max-w-sm rounded-xl border border-border bg-surface px-6 py-7">
        <div className="mb-5 flex items-center justify-between">
          <span className="font-mono text-[11px] uppercase tracking-[0.14em] text-ns-muted">
            kiln cycle
          </span>
        </div>
        <div className="h-40 w-full">
          <SlumpMouldDrape />
        </div>
      </div>

      <p className="max-w-xs text-center font-mono text-[10px] text-ns-muted">
        the centre sags first — the drape spreads toward the edges over 4.5s,
        holds, then lifts flat for the next blank
      </p>
    </div>
  );
}

Component

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>
  );
}

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

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.

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——