Skip to main content

ns-ui

Damask Float

A feature grid woven from one reversible damask cloth — figure and ground are the same thread in the same colour, differing only in float direction, so hovering a cell flips the anisotropic highlight instantly with no fade and no translate.

Usage

registry/core/damask-float/demo.tsx
"use client";

import { DamaskFloatCard, DamaskFloatGrid } from "./component";

// Filled solid glyphs, not stroke outlines — component.tsx rasterises
// `iconPath` with ctx.fill(), so a hairline stroke path would fill to
// nothing. Each is a single closed (or compound) path in a 0 0 24 24
// viewBox, sized and weighted to read clearly once woven into cloth at
// card scale.
const CIRCLE_PATH = "M12 2a10 10 0 100 20 10 10 0 000-20z";
const DIAMOND_PATH = "M12 2l10 10-10 10L2 12z";
const TRIANGLE_PATH = "M12 3l9 18H3z";
const BOLT_PATH = "M13 2L4 14h6l-1 8 10-12h-6l1-8z";
const HEXAGON_PATH = "M12 2l8.7 5v10L12 22l-8.7-5V7z";
const PLUS_PATH = "M9 2h6v7h7v6h-7v7H9v-7H2V9h7z";

const FEATURES = [
  {
    heading: "One source of truth",
    body: "Every surface reads the same record, so a change lands everywhere at once.",
    iconPath: CIRCLE_PATH,
  },
  {
    heading: "Typed end to end",
    body: "Your schema generates the client, so a wrong field fails at build time.",
    iconPath: DIAMOND_PATH,
  },
  {
    heading: "Permissions per field",
    body: "Scope what a role can read or write without forking the data model.",
    iconPath: TRIANGLE_PATH,
  },
  {
    heading: "Live collaboration",
    body: "Cursors, comments and edits arrive in order, with no refresh.",
    iconPath: BOLT_PATH,
  },
  {
    heading: "Complete audit trail",
    body: "Every write records who changed what and when, and stays queryable.",
    iconPath: HEXAGON_PATH,
  },
  {
    heading: "Bring your own data",
    body: "Import existing tables as they are and map the columns later.",
    iconPath: PLUS_PATH,
  },
];

export default function DamaskFloatDemo() {
  return (
    <main className="flex h-screen w-full flex-col overflow-hidden bg-background text-foreground">
      {/* the whole section is height-bounded so all six cells compose inside
          the card frame instead of running off its bottom edge */}
      <section className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-5 px-6 py-6">
        <header className="shrink-0">
          <p className="font-mono text-[11px] uppercase tracking-widest text-ns-muted">ns-ui / damask-float</p>
          <h1 className="mt-2 text-xl font-semibold tracking-tight">Built on one surface</h1>
        </header>

        <DamaskFloatGrid
          aria-label="Feature grid woven as one reversible damask cloth"
          className="grid min-h-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 sm:grid-rows-3 md:grid-cols-3 md:grid-rows-2"
        >
          {FEATURES.map((f) => (
            <DamaskFloatCard key={f.heading} heading={f.heading} body={f.body} iconPath={f.iconPath} />
          ))}
        </DamaskFloatGrid>
      </section>
    </main>
  );
}

Component

registry/core/damask-float/component.tsx
"use client";

import {
  createContext,
  useContext,
  useEffect,
  useId,
  useRef,
  type HTMLAttributes,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from "react";

// ---------------------------------------------------------------------------
// DamaskFloat — a feature grid woven from ONE cloth. Damask is figured cloth
// made from a single thread in a single colour: the pattern exists only
// because the ground and the figure are reciprocal satin structures (warp
// floats over the ground, weft floats over the figure), so the two regions
// share identical fibre and identical colour and differ only in WHICH
// direction the exposed thread runs. That azimuth difference changes the
// surface's specular anisotropy — nothing else — which is why this is
// natively monochrome and why hover can flip figure and ground with no fade
// and no translate: reversing S at a texel is reversing which structure is
// on top, not crossfading a colour.
//
// Per cell, a 2D canvas evaluates a Kajiya-Kay anisotropic term per texel
// against a FIXED light (elevation 34deg, azimuth 15deg) with a straight-on
// view, using a satin structure grid at pitch P = 0.018 * min(w,h): a 5-end
// satin (counter-step 2) gives every texel a binding-point mask that breaks
// the float and darkens it, which is what stops the two regions reading as
// flat greys. The raw anisotropic response only DRIVES the specular streak;
// the base tone of ground vs figure is pinned to fixed per-theme luminance
// stops (measured warp:weft ratio ~1.6-2.2:1) so contrast survives even
// where the streak formula is weak, per the light-theme floor rule.
//
// Alive at rest, unconditionally: (1) TAKE-UP — the cloth's border motif
// advances upward at TAKEUP = 0.021*min(w,h) px/s and wraps, so new cloth
// is always entering the frame; the pinned figure stays fixed in cell space
// so it never scrolls out. (2) LOOM SWAY — the light stays fixed but the
// cloth's own grain azimuth oscillates +-3.5deg on an 8.7s period plus a
// second, incommensurate +-1.2deg harmonic at 13.4s, so the specular streak
// (a cos^26 lobe) visibly travels across the surface and the state never
// repeats on a single beat. Neither mechanism converges; a still cloth is
// not a state this component has.
//
// Hover reverses S -> 1-S for texels the pointer's entry has reached, a
// front expanding at 0.9*min(w,h) px/s from the entry point — a real change
// in which structure is exposed, not an opacity cross-fade. Leaving the
// cell reverts instantly (a real damask has no "in-between" float either).
//
// Text sits on a plain-weave (tabby) patch that the front passes BEHIND —
// tabby has no floats and no anisotropy, so it is pinned to a fixed L in
// each theme regardless of S or the front, and it additionally sits under a
// standard token scrim (bg-background/70 backdrop-blur) per house rule, so
// the DOM heading/body are never depending on the canvas alone for contrast.
//
// Tokens: --background/--foreground read via getComputedStyle and re-read on
// a MutationObserver watching documentElement's class; every rendered pixel
// is a channel-wise blend of those two live RGB triples, never a literal
// colour. --ns-accent never appears in the streak — it is interaction chrome
// only (the focus ring), which is what the climactic-moment rule asks for.
// ---------------------------------------------------------------------------

// ---- shared numbers ---------------------------------------------------

const P_FACTOR = 0.018; // thread pitch as a fraction of the cell's smaller dimension
const TAKEUP_FACTOR = 0.021; // px/s, fraction of smaller dimension
const FRONT_SPEED_FACTOR = 0.9; // px/s, fraction of smaller dimension
const SATIN_PERIOD = 5; // 5-end satin
const SATIN_COUNTER_STEP = 2;
const SWAY_PERIOD_1_MS = 8700;
const SWAY_AMP_1_DEG = 3.5;
const SWAY_PERIOD_2_MS = 13400;
const SWAY_AMP_2_DEG = 1.2;
const STATIC_TIME_MS = 5400; // reduced-motion freeze frame, named in the spec
const LIGHT_ELEV_DEG = 34;
const LIGHT_AZ_DEG = 15;
const KD = 0.34;
const KS = 0.52;
const N_EXP = 26;
const DEG2RAD = Math.PI / 180;

const LIGHT_VEC = (() => {
  const elev = LIGHT_ELEV_DEG * DEG2RAD;
  const az = LIGHT_AZ_DEG * DEG2RAD;
  return { x: Math.cos(elev) * Math.cos(az), y: Math.cos(elev) * Math.sin(az) };
})();

// Target luminances (0 = darkest, 1 = lightest), per theme, from the spec's
// measured stops. Ground (warp-face) reads brighter than figure (weft-face)
// in both themes; only the bias/contrast move between themes, never the
// direction — see readTargetL below.
const GROUND_BODY_L = { light: 0.58, dark: 0.46 };
const FIGURE_BODY_L = { light: 0.29, dark: 0.2 };
const BINDING_RATIO = { light: 0.08 / 0.29, dark: 0.05 / 0.2 }; // binding point darkens the float by this fraction
const GROUND_STREAK_L = { light: 0.84, dark: 0.78 };
const STREAK_PEAK_L = { light: 0.96, dark: 0.98 };
const TABBY_L = { light: 0.68, dark: 0.24 };

function clamp01(v: number): number {
  return v < 0 ? 0 : v > 1 ? 1 : v;
}
function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}
function smoothstep(x: number, e0: number, e1: number): number {
  const t = clamp01((x - e0) / (e1 - e0));
  return t * t * (3 - 2 * t);
}

/** Deterministic integer hash -> [0,1), used for the satin twist jitter so
 * the reduced-motion frame is byte-stable — never Math.random(). */
function hash2(ix: number, iy: number): number {
  let h = (ix | 0) * 374761393 + (iy | 0) * 668265263;
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  h ^= h >>> 16;
  return (h >>> 0) / 4294967296;
}

function swayAngleDeg(elapsedMs: number): number {
  const a = Math.sin((2 * Math.PI * elapsedMs) / SWAY_PERIOD_1_MS) * SWAY_AMP_1_DEG;
  const b = Math.sin((2 * Math.PI * elapsedMs) / SWAY_PERIOD_2_MS) * SWAY_AMP_2_DEG;
  return a + b;
}

function prefersReducedMotion(): boolean {
  return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

// ---- token read (zero colour literals: always live RGB from --background/--foreground) ----

let probeCanvas: HTMLCanvasElement | null = null;
let probeCtx: CanvasRenderingContext2D | null = null;

function colorToRGB(cssColor: string): [number, number, number] {
  if (!probeCanvas) {
    probeCanvas = document.createElement("canvas");
    probeCanvas.width = 1;
    probeCanvas.height = 1;
    probeCtx = probeCanvas.getContext("2d", { willReadFrequently: true });
  }
  if (!probeCtx || !cssColor) return [0, 0, 0];
  probeCtx.fillStyle = "#000";
  probeCtx.fillStyle = cssColor;
  probeCtx.fillRect(0, 0, 1, 1);
  const d = probeCtx.getImageData(0, 0, 1, 1).data;
  return [d[0], d[1], d[2]];
}

interface TokenRGB {
  bg: [number, number, number];
  fg: [number, number, number];
  bgY: number; // simple channel-average proxy for luminance, 0..1
  fgY: number;
  isDark: boolean;
}

function readTokens(): TokenRGB {
  const cs = getComputedStyle(document.documentElement);
  const bg = colorToRGB(cs.getPropertyValue("--background").trim());
  const fg = colorToRGB(cs.getPropertyValue("--foreground").trim());
  const bgY = (bg[0] + bg[1] + bg[2]) / 765;
  const fgY = (fg[0] + fg[1] + fg[2]) / 765;
  return { bg, fg, bgY, fgY, isDark: bgY < 0.5 };
}

/** target L (0=darkest,1=lightest) -> alpha of --foreground composited over
 * --background, solved from the LIVE token luminances so a re-skin (theme
 * swap, or any token change caught by the MutationObserver) is honoured
 * automatically rather than baked in. */
function alphaForTargetL(targetL: number, tok: TokenRGB): number {
  const span = tok.fgY - tok.bgY;
  if (Math.abs(span) < 1e-4) return 0;
  return clamp01((targetL - tok.bgY) / span);
}

function blendChannel(bg: number, fg: number, alpha: number): number {
  return bg + (fg - bg) * alpha;
}

// ---- shared clock / token context (one rAF loop, N cells) --------------

interface TickPayload {
  elapsedMs: number;
  tokens: TokenRGB;
  qualityScale: number; // 1 = full buffer res, shrinks under sustained slow frames
}

type TickFn = (payload: TickPayload) => void;

interface GridApi {
  subscribe(fn: TickFn): () => void;
  reportFrameCost(ms: number): void;
}

const GridContext = createContext<GridApi | null>(null);

const QUALITY_STEPS = [1, 0.75, 0.55];
const FRAME_BUDGET_MS = 8;
const SLOW_SUSTAIN_MS = 900;

export interface DamaskFloatGridProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
  children: ReactNode;
}

export function DamaskFloatGrid({ children, className = "", ...rest }: DamaskFloatGridProps) {
  const subsRef = useRef<Set<TickFn>>(new Set());
  const apiRef = useRef<GridApi>({
    subscribe(fn) {
      subsRef.current.add(fn);
      return () => {
        subsRef.current.delete(fn);
      };
    },
    reportFrameCost() {
      /* replaced in the effect below */
    },
  });

  useEffect(() => {
    if (prefersReducedMotion() || typeof window === "undefined") return;

    let tokens = readTokens();
    const themeObserver = new MutationObserver(() => {
      tokens = readTokens();
    });
    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    let qualityScale = 1;
    let overBudgetSinceMs = -1;
    let stepIndex = 0;
    apiRef.current.reportFrameCost = (ms: number) => {
      const now = performance.now();
      if (ms > FRAME_BUDGET_MS) {
        if (overBudgetSinceMs < 0) overBudgetSinceMs = now;
        if (now - overBudgetSinceMs > SLOW_SUSTAIN_MS && stepIndex < QUALITY_STEPS.length - 1) {
          stepIndex += 1;
          qualityScale = QUALITY_STEPS[stepIndex];
          overBudgetSinceMs = -1;
        }
      } else {
        overBudgetSinceMs = -1;
      }
    };

    const origin = performance.now();
    let raf = 0;
    const loop = () => {
      const elapsedMs = performance.now() - origin;
      const payload: TickPayload = { elapsedMs, tokens, qualityScale };
      subsRef.current.forEach((fn) => fn(payload));
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => {
      cancelAnimationFrame(raf);
      themeObserver.disconnect();
    };
  }, []);

  return (
    <GridContext.Provider value={apiRef.current}>
      <div {...rest} className={className}>
        {children}
      </div>
    </GridContext.Provider>
  );
}

// ---- card ----------------------------------------------------------------

export interface DamaskFloatCardProps {
  /** Card heading, drawn as real DOM text over a fixed plain-weave patch. */
  heading: string;
  /** Supporting copy, same patch. */
  body: string;
  /** SVG path `d` (0 0 24 24 viewBox) rasterised into the cloth as the
   * pinned figure — this is the "icon", woven rather than printed. */
  iconPath?: string;
  href?: string;
  className?: string;
}

const DEFAULT_ICON_PATH =
  "M12 3l2.2 5.8L20 11l-5.8 2.2L12 19l-2.2-5.8L4 11l5.8-2.2L12 3z";

interface PatchRect {
  x: number;
  y: number;
  w: number;
  h: number;
}

export function DamaskFloatCard({ heading, body, iconPath, href, className = "" }: DamaskFloatCardProps) {
  const reactId = useId();
  const hostRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const patchElRef = useRef<HTMLDivElement>(null);
  const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
  const bufCanvasRef = useRef<HTMLCanvasElement | null>(null);
  const bufCtxRef = useRef<CanvasRenderingContext2D | null>(null);
  const iconMaskRef = useRef<Uint8Array | null>(null);
  const sizeRef = useRef({ cssW: 0, cssH: 0, dpr: 1, bufW: 0, bufH: 0, minDim: 1 });
  const patchRef = useRef<PatchRect>({ x: 0, y: 0, w: 0, h: 0 });
  const frontRef = useRef<{ ox: number; oy: number; startMs: number } | null>(null);
  const visibleRef = useRef(true);
  const qualityRef = useRef(1);
  const grid = useContext(GridContext);

  useEffect(() => {
    const host = hostRef.current;
    const canvas = canvasRef.current;
    if (!host || !canvas) return;

    const ctx = canvas.getContext("2d");
    ctxRef.current = ctx;
    const bufCanvas = document.createElement("canvas");
    bufCanvasRef.current = bufCanvas;
    bufCtxRef.current = bufCanvas.getContext("2d", { willReadFrequently: true });
    if (!ctx || !bufCtxRef.current) return;

    const rebuildIconMask = () => {
      const { bufW, bufH, minDim } = sizeRef.current;
      if (bufW <= 0 || bufH <= 0) return;
      const iconCanvas = document.createElement("canvas");
      iconCanvas.width = bufW;
      iconCanvas.height = bufH;
      const ictx = iconCanvas.getContext("2d");
      if (!ictx) return;
      const box = minDim * 0.4 * (bufW / sizeRef.current.cssW || 1);
      const scale = box / 24;
      ictx.save();
      ictx.translate(bufW / 2 - box / 2, bufH * 0.16);
      ictx.scale(scale, scale);
      ictx.fillStyle = "#000";
      ictx.fill(new Path2D(iconPath ?? DEFAULT_ICON_PATH));
      ictx.restore();
      const data = ictx.getImageData(0, 0, bufW, bufH).data;
      const mask = new Uint8Array(bufW * bufH);
      for (let i = 0; i < mask.length; i++) mask[i] = data[i * 4 + 3] > 96 ? 1 : 0;
      iconMaskRef.current = mask;
    };

    const measure = () => {
      const rect = host.getBoundingClientRect();
      const cssW = Math.max(1, rect.width);
      const cssH = Math.max(1, rect.height);
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const minDim = Math.min(cssW, cssH);
      const q = qualityRef.current;
      // half device resolution per the spec, further shrunk by the adaptive
      // quality scale, and hard-capped so a large card never over-spends.
      const bufW = Math.min(320, Math.round(cssW * dpr * 0.5 * q));
      const bufH = Math.min(320, Math.round(cssH * dpr * 0.5 * q));
      sizeRef.current = { cssW, cssH, dpr, bufW, bufH, minDim };

      canvas.width = Math.round(cssW * dpr);
      canvas.height = Math.round(cssH * dpr);
      canvas.style.width = `${cssW}px`;
      canvas.style.height = `${cssH}px`;
      bufCanvas.width = bufW;
      bufCanvas.height = bufH;

      if (patchElRef.current) {
        const pr = patchElRef.current.getBoundingClientRect();
        patchRef.current = { x: pr.left - rect.left, y: pr.top - rect.top, w: pr.width, h: pr.height };
      }
      rebuildIconMask();
      // NO PAINT BEFORE THE FIRST TOKEN READ: draw() below pulls tokens from
      // the tick payload / an explicit read, never from a hardcoded default.
      if (staticTokensRef.current) draw(staticElapsedRef.current, staticTokensRef.current);
    };

    const staticTokensRef: { current: TokenRGB | null } = { current: null };
    const staticElapsedRef = { current: STATIC_TIME_MS };

    const draw = (elapsedMs: number, tokens: TokenRGB) => {
      const bctx = bufCtxRef.current;
      const { bufW, bufH, cssW, minDim } = sizeRef.current;
      if (!bctx || bufW <= 0 || bufH <= 0) return;

      // Buffer pixels per CSS pixel — folds in DPR, the half-resolution rule
      // and the adaptive quality scale all at once, so every length below
      // (thread pitch, take-up, front radius) only has to be defined once,
      // in CSS px, and converted through this single factor.
      const bufPerCss = bufW / cssW;
      const cssPerBuf = cssW / bufW;
      const P = Math.max(1.2, P_FACTOR * minDim * bufPerCss);

      const takeupBuf = TAKEUP_FACTOR * minDim * bufPerCss * (elapsedMs / 1000);
      const repeatBuf = P * 8;
      const swayDeg = swayAngleDeg(elapsedMs);

      const front = frontRef.current;
      const frontRadiusCss = front ? FRONT_SPEED_FACTOR * minDim * ((elapsedMs - front.startMs) / 1000) : -1;

      const img = bctx.createImageData(bufW, bufH);
      const d = img.data;
      const iconMask = iconMaskRef.current;
      const patch = patchRef.current;

      const groundL = tokens.isDark ? GROUND_BODY_L.dark : GROUND_BODY_L.light;
      const figureL = tokens.isDark ? FIGURE_BODY_L.dark : FIGURE_BODY_L.light;
      const bindRatio = tokens.isDark ? BINDING_RATIO.dark : BINDING_RATIO.light;
      const streakUnderL = tokens.isDark ? GROUND_STREAK_L.dark : GROUND_STREAK_L.light;
      const streakPeakL = tokens.isDark ? STREAK_PEAK_L.dark : STREAK_PEAK_L.light;
      const tabbyL = tokens.isDark ? TABBY_L.dark : TABBY_L.light;

      for (let by = 0; by < bufH; by++) {
        const yCloth = by + takeupBuf;
        const iyBind = Math.floor(((yCloth % repeatBuf) + repeatBuf) % repeatBuf / P);
        const xCssBase = 0;
        const yCss = by * cssPerBuf;
        const inPatchY = yCss >= patch.y - 4 && yCss <= patch.y + patch.h + 4;
        for (let bx = 0; bx < bufW; bx++) {
          const idx = (by * bufW + bx) * 4;
          const xCss = xCssBase + bx * cssPerBuf;

          if (inPatchY && xCss >= patch.x - 4 && xCss <= patch.x + patch.w + 4) {
            const alpha = alphaForTargetL(tabbyL, tokens);
            d[idx] = blendChannel(tokens.bg[0], tokens.fg[0], alpha);
            d[idx + 1] = blendChannel(tokens.bg[1], tokens.fg[1], alpha);
            d[idx + 2] = blendChannel(tokens.bg[2], tokens.fg[2], alpha);
            d[idx + 3] = 255;
            continue;
          }

          const ix = Math.floor(bx / P);
          const maskIdx = by * bufW + bx;
          const pinned = iconMask ? iconMask[maskIdx] === 1 : 0;
          const diamondU = ((bx % repeatBuf) + repeatBuf) % repeatBuf - repeatBuf / 2;
          const diamondV = ((yCloth % repeatBuf) + repeatBuf) % repeatBuf - repeatBuf / 2;
          const motif = Math.abs(diamondU) + Math.abs(diamondV) < repeatBuf * 0.28 ? 1 : 0;
          let S = pinned || motif ? 1 : 0;

          if (front && frontRadiusCss >= 0) {
            const dx = xCss - front.ox;
            const dy = yCss - front.oy;
            if (dx * dx + dy * dy < frontRadiusCss * frontRadiusCss) S = 1 - S;
          }

          const bindingKey = ((ix + SATIN_COUNTER_STEP * iyBind) % SATIN_PERIOD + SATIN_PERIOD) % SATIN_PERIOD === 0;

          const baseAzimuthDeg = S ? 0 : 90; // ground assigned the azimuth further from the light -> brighter lobe, matching the measured warp>weft ratio
          const jitterDeg = (hash2(ix, iyBind) - 0.5) * 8;
          const phi = (baseAzimuthDeg + jitterDeg + swayDeg) * DEG2RAD;
          const Tx = Math.cos(phi);
          const Ty = Math.sin(phi);
          const sinLT = Math.max(0, LIGHT_VEC.x * Tx + LIGHT_VEC.y * Ty);
          const cosLT = Math.sqrt(Math.max(0, 1 - sinLT * sinLT));
          const F = KD * sinLT + KS * Math.pow(cosLT, N_EXP);

          let baseL = S ? figureL : groundL;
          if (bindingKey) baseL *= bindRatio;

          const streakFactor = smoothstep(F, 0.35, 0.86);
          const crownFactor = smoothstep(F, 0.7, 0.86);
          let targetL = lerp(baseL, streakUnderL, streakFactor);
          targetL = lerp(targetL, streakPeakL, crownFactor);

          const alpha = alphaForTargetL(targetL, tokens);
          d[idx] = blendChannel(tokens.bg[0], tokens.fg[0], alpha);
          d[idx + 1] = blendChannel(tokens.bg[1], tokens.fg[1], alpha);
          d[idx + 2] = blendChannel(tokens.bg[2], tokens.fg[2], alpha);
          d[idx + 3] = 255;
        }
      }

      bctx.putImageData(img, 0, 0);
      ctx.imageSmoothingEnabled = true;
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(bufCanvas, 0, 0, bufW, bufH, 0, 0, canvas.width, canvas.height);
    };

    const ro = new ResizeObserver(() => measure());
    ro.observe(host);

    let io: IntersectionObserver | null = null;
    let onVisChange: (() => void) | null = null;

    if (prefersReducedMotion()) {
      const tokens = readTokens();
      staticTokensRef.current = tokens;
      measure();
      draw(STATIC_TIME_MS, tokens);
      const themeObserver = new MutationObserver(() => {
        const t = readTokens();
        staticTokensRef.current = t;
        draw(STATIC_TIME_MS, t);
      });
      themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
      return () => {
        ro.disconnect();
        themeObserver.disconnect();
      };
    }

    // First token read happens here, before the first measure()/draw() —
    // measure() only paints once staticTokensRef (used as the "last known
    // tokens" cache for resize-triggered repaints) is populated.
    const initialTokens = readTokens();
    staticTokensRef.current = initialTokens;
    staticElapsedRef.current = 0;
    measure();

    io = new IntersectionObserver((entries) => {
      visibleRef.current = entries[0]?.isIntersecting ?? true;
    }, { threshold: 0 });
    io.observe(host);
    onVisChange = () => {
      visibleRef.current = visibleRef.current && document.visibilityState === "visible";
    };
    document.addEventListener("visibilitychange", onVisChange);

    const tick = ({ elapsedMs, tokens, qualityScale }: TickPayload) => {
      staticTokensRef.current = tokens;
      staticElapsedRef.current = elapsedMs;
      if (!visibleRef.current || document.visibilityState !== "visible") return;
      if (qualityScale !== qualityRef.current) {
        qualityRef.current = qualityScale;
        measure();
      }
      const t0 = performance.now();
      draw(elapsedMs, tokens);
      grid?.reportFrameCost(performance.now() - t0);
    };

    let unsubscribe: (() => void) | null = null;
    let raf = 0;
    if (grid) {
      unsubscribe = grid.subscribe(tick);
    } else {
      // Standalone fallback: own rAF loop and own token watcher, mirroring
      // GrazingLight's convention so a card works without a wrapping grid.
      let tokens = initialTokens;
      const themeObserver = new MutationObserver(() => {
        tokens = readTokens();
      });
      themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
      const origin = performance.now();
      const loop = () => {
        tick({ elapsedMs: performance.now() - origin, tokens, qualityScale: qualityRef.current });
        raf = requestAnimationFrame(loop);
      };
      raf = requestAnimationFrame(loop);
      unsubscribe = () => {
        cancelAnimationFrame(raf);
        themeObserver.disconnect();
      };
    }

    return () => {
      ro.disconnect();
      io?.disconnect();
      if (onVisChange) document.removeEventListener("visibilitychange", onVisChange);
      unsubscribe?.();
    };
  }, [grid, iconPath]);

  const onPointerEnter = (e: ReactPointerEvent<HTMLDivElement>) => {
    const rect = hostRef.current?.getBoundingClientRect();
    if (!rect) return;
    frontRef.current = {
      ox: e.clientX - rect.left,
      oy: e.clientY - rect.top,
      startMs: performance.now(),
    };
  };
  const onPointerLeave = () => {
    frontRef.current = null;
  };

  const patchId = `ns-df-patch-${reactId.replace(/:/g, "")}`;

  return (
    <div
      ref={hostRef}
      onPointerEnter={onPointerEnter}
      onPointerLeave={onPointerLeave}
      className={`ns-df-card relative flex min-h-[150px] flex-col overflow-hidden rounded-lg border border-border ${className}`}
    >
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" aria-hidden="true" />
      {href ? (
        // Stretched-link pattern: the whole card is the hit target and the
        // accessible name, but the host stays a plain div so the pointer
        // events driving the front reversal are never entangled with anchor
        // semantics or a second focus ring.
        <a
          href={href}
          className="absolute inset-0 z-20 rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-accent"
          aria-labelledby={patchId}
        />
      ) : null}
      <div
        ref={patchElRef}
        id={patchId}
        className="relative z-10 mt-auto flex flex-col gap-1 rounded-md bg-background/70 p-3.5 backdrop-blur-sm"
      >
        <h3 className="text-[0.9375rem] font-semibold leading-snug tracking-tight text-foreground">{heading}</h3>
        <p className="text-[0.8125rem] leading-snug text-ns-muted">{body}</p>
      </div>
    </div>
  );
}

export default DamaskFloatCard;

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

Use when the feature grid where the cells should carry the mechanic as one continuous material rather than a picture with borders around each card — pick damask-float when the reveal is a real change in surface structure (figure/ground reversal) rather than a moving light. Pick grazing-light instead when the figure should read as carved RELIEF that a moving light source rakes across (a height field, not a planar weave); pick ascii-charset-shift instead when the grid's identity should be glyph/box-drawing character substitution rather than a woven material.

Install

npx shadcn add https://design.helpmarq.com/r/damask-float.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 two exports. DamaskFloatGrid<{children}> is an optional wrapping provider that owns ONE rAF loop and a MutationObserver on documentElement's class, broadcasting {elapsedMs, tokens, qualityScale} to every subscribed card each frame (same single-loop idiom as this repo's GrazingLightGrid/MagneticDock) — a card mounted without one runs the identical loop standalone. DamaskFloatCard<{heading, body, iconPath?, href?, className?}> is a single card: an absolutely-positioned 2D canvas fills the card (w-full h-full, DPR-aware backing store capped at 2, rendered into an offscreen buffer at HALF that resolution then upscaled via ctx.drawImage with imageSmoothingEnabled, ResizeObserver on the host element re-deriving every length), with the heading/body as real DOM text in a bg-background/70 backdrop-blur-sm scrim anchored to the card's bottom (an optional href renders a stretched <a> overlay for the hit target, keeping the host div's own pointer events dedicated to the hover front below). THE MATERIAL: damask is figured cloth from ONE thread in ONE colour — ground and figure are reciprocal satin structures (5-end satin, counter-step 2) that differ only in which direction the exposed thread runs, so the whole component is monochrome by physical necessity, not by constraint. Per texel: thread pitch P = 0.018 * min(cardW, cardH) (in buffer-pixel space, after DPR and the half-resolution and adaptive-quality factors are folded in). S (0=ground/warp-face, 1=figure/weft-face) comes from a rasterised icon mask (the `iconPath`, an SVG path `d` in a 0 0 24 24 viewBox, filled via Path2D onto an offscreen canvas once per resize — pinned in cell coordinates, never scrolling) OR-ed with a procedural diamond/lozenge border motif tiled at 8*P and evaluated against a cloth-space Y that DOES scroll. Binding points break the float: bindingKey = (ix + 2*iy) mod 5 === 0 where ix/iy are integer thread indices, darkening that texel's target luminance by the ratio 0.08/0.29 (light) or 0.05/0.20 (dark) of its region's base tone — this is what stops ground/figure reading as two flat greys. SHADING: a Kajiya-Kay anisotropic term against a FIXED light (elevation 34deg, azimuth 15deg, straight-on view) with kd=0.34, ks=0.52, n=26: sinLT = max(0, L.T) where T is the thread tangent from its azimuth (ground assigned 0deg, figure 90deg, each jittered +-4deg by an integer hash of (ix,iy) — never Math.random, so the reduced-motion frame stays byte-stable), cosLT = sqrt(1-sinLT^2), F = kd*sinLT + ks*cosLT^n. F only DRIVES the specular streak (smoothstepped onto two escalating target-luminance stops, 'ground under streak' and 'streak peak on a float crown'); the base tone of ground vs figure is pinned to fixed per-theme luminance targets (ground 0.58/0.46, figure 0.29/0.20, light/dark) so the measured ~1.6-2.2:1 warp:weft contrast survives even where F is weak. Every target luminance is converted to a pixel by solving alpha = (targetL - bgY)/(fgY - bgY) against the LIVE --background/--foreground token luminances (read via getComputedStyle, re-read on the class MutationObserver) and blending each RGB channel bg+alpha*(fg-bg) — zero colour literals, and a theme swap or any token change is honoured automatically since nothing is baked in. ALIVE AT REST, unconditionally: (1) take-up — the border motif's cloth-space Y advances at 0.021*min(w,h) px/s and wraps, so new cloth is always entering the frame while the pinned icon never scrolls; (2) loom sway — the light stays fixed but the cloth's own grain azimuth oscillates on two incommensurate sinusoids, +-3.5deg at an 8.7s period plus +-1.2deg at 13.4s, so the n=26 specular streak visibly travels and the state never repeats on one beat. Neither mechanism converges. HOVER: on pointerenter, record the entry point in card-local CSS px and the entry time; every texel within a front expanding from that point at 0.9*min(w,h) px/s gets S flipped to 1-S — a real change in which satin structure is exposed, computed before the luminance/streak math runs, never a post-hoc opacity crossfade. Pointerleave reverts instantly (no reverse-front, matching that a real float has no in-between state). A fixed-size 'tabby' patch — matching the DOM scrim's measured rect — is written AFTER the front/reversal logic every frame, pinned to a flat target luminance (0.68 light / 0.24 dark) regardless of S or the front, so text contrast never depends on the weave state; verify this at the frame where the front is crossing the patch boundary. HOST: prefers-reduced-motion draws exactly one frame at STATIC_TIME = 5.4s (sway near its +2.9deg extremum, off the flat t=0 minimum) and never runs the rAF loop or any listener; that frame is re-rendered on resize/theme change but is otherwise byte-stable. IntersectionObserver (threshold 0) and visibilitychange both gate the tick. An adaptive quality governor lives in DamaskFloatGrid: each card reports its own draw duration; if it stays over an 8ms budget for a sustained 900ms the shared buffer-resolution scale steps down (1 -> 0.75 -> 0.55), never on frame count or device sniffing. --ns-accent appears nowhere in the shading — only as the focus ring on an optional stretched-link href.

Props

PropTypeDefaultDescription
headingstring—Card heading, drawn as real DOM text over a fixed plain-weave patch.
bodystring—Supporting copy, same patch.
iconPath?string—SVG path `d` (0 0 24 24 viewBox) rasterised into the cloth as the pinned figure — this is the "icon", woven rather than printed.
href?string——
className?string——