Skip to main content

ns-ui

Slate Gauge

A testimonial wall built as real double-lap slating: each quote is a slate hung on a batten, and only its computed gauge — the exposed margin — is ever visible at rest. The wall's geometry never changes; the only motion is one slate lifting on its nail to read.

Usage

registry/core/slate-gauge/demo.tsx
"use client";

import { SlateGauge } from "./component";

export default function SlateGaugeDemo() {
  return (
    <main className="flex h-screen w-full items-center justify-center overflow-hidden bg-background p-6">
      {/* the wall's geometry never moves — tab through the slates or click
          one to lift it and read the full quote; watch the wall itself for
          a few seconds and one centre-nailed slate down the gust's path
          will clatter on its own */}
      <div className="h-full w-full">
        <SlateGauge />
      </div>
    </main>
  );
}

Component

registry/core/slate-gauge/component.tsx
"use client";

import { useLayoutEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// SlateGauge — a testimonial wall built as double-lap slating. The wall's
// geometry never changes: five diminishing courses of slates are hung so
// each course laps the one below it, and the only part of any slate that is
// ever visible at rest is its computed GAUGE — the exposed margin a slater
// derives from the slate's own height, never chosen by hand:
//
//   gauge = (height - lap - head allowance) / 2
//
// Slates are rendered as absolutely positioned <button aria-expanded> shells
// (the click target) each wrapping an <article> (the visual face). At rest
// every course's article is exactly its own GAUGE tall — courses are laid
// edge to edge with no overlap, so the wall never needs a clip-path to hide
// the covered nine-tenths of each slate: it simply never paints it. Lifting
// a slate grows that one article, and only that one, from gauge to its true
// height and raises it above its neighbours (z-index), which is what reveals
// the rest of the quote and is also the only thing that ever overlaps in
// this layout. The quote text itself is never display:none at any height —
// the SHORT box just clips it, so a screen reader, Ctrl-F and text selection
// still reach the full sentence whether or not it is currently showing.
//
// Reading a slate rotates it about its NAIL LINE (18px below its head, i.e.
// near-top) to -34deg on an underdamped spring (k=210, c=19 — the spec's own
// physical numbers, not a hand-tuned easing curve), which is what produces
// both the fixed -34deg rest angle AND the single ~3deg overshoot as an
// emergent property of the spring rather than a hardcoded keyframe list; the
// same spring relaxes back to 0 on release, so "lift" and "return" are one
// mechanism read in two directions. Only one slate is ever lifted — lifting
// a second sets both aria-expanded values in the same state commit.
//
// TWO unforced processes keep the wall alive at rest without the wall's own
// layout ever moving (BUILDER's alive-at-rest test, and DECISIONS D3's ban on
// a moving band leaving its wake as a dead still image, though this
// component's band is a light/wind field over a STATIC lattice rather than a
// process it matures, so there is no "mature region" to freeze):
//   1. An idle gust 190px wide sweeps left-to-right on a 4.8s loop. A
//      deterministic 3-in-17 subset of slates is centre-nailed; whenever a
//      centre-nailed slate's tail sits inside the gust it lifts 2.5-5px on
//      its own short underdamped spring and clatters back.
//   2. A light azimuth swings +-14deg on a 22s cosine. It never touches any
//      slate's own face luminance — only the length and side of the cast
//      shadow at the top of the course below, via ONE root-level CSS custom
//      property every slate's box-shadow reads, so the whole wall's relief
//      inverts together without touching N individual DOM nodes per frame.
// ---------------------------------------------------------------------------

export interface SlateTestimonial {
  id: string;
  quote: string;
  name: string;
  role: string;
}

export interface SlateGaugeProps {
  /** Testimonials, filled row-major (course 1 left-to-right, then course 2, ...). */
  items?: SlateTestimonial[];
  /** Slates per course. @default 3 */
  columns?: number;
  /** Heading rendered above the wall. */
  heading?: string;
  className?: string;
}

// ---- geometry: exactly the spec's course table ---------------------------
// gauge = (h - LAP - HEAD) / 2, diminishing ~0.915x per course.
const LAP = 20;
const HEAD = 8;
const COURSE_H = [132, 120, 110, 101, 94];

// Type metrics, in the same unscaled design units as the course table — the
// quote is rendered at FONT_PX * scale with LINE_RATIO leading and PAD_Y *
// scale of padding, so a course's exposure can be expressed here once and
// then simply multiplied by the fit scale like every other box dimension.
const FONT_PX = 10;
const LINE_RATIO = 1.36;
const LINE_PX = FONT_PX * LINE_RATIO;
const PAD_Y = 6;

// A course's EXPOSURE is its computed gauge quantised down to a whole number
// of text lines. The gauge is what the slater derives; the quantisation is
// what stops the lap from falling through the middle of a line of type, which
// is the difference between reading as a slate lapping its neighbour and
// reading as a sentence that got cut off.
function exposureFor(gauge: number): number {
  // Only the HEAD padding is inside the exposure. Counting the foot padding
  // too left exactly one padding's worth of the next line showing through the
  // bottom of the strip — a sliced half-line of attribution, which is the one
  // thing that reads as broken rather than as lapped.
  const lines = Math.max(2, Math.floor((gauge - PAD_Y) / LINE_PX));
  return PAD_Y + lines * LINE_PX;
}

const COURSES = COURSE_H.map((h) => {
  const gauge = (h - LAP - HEAD) / 2;
  return { h, gauge, exposure: exposureFor(gauge) };
});
const SLATE_W = 168;
const COL_OFFSET = SLATE_W / 2; // half a slate — breaks the side lap
const NAIL_Y = 18; // rotation origin, below the head
const MIN_HIT = 44; // WCAG minimum, padded upward into transparent space only
const SCALE_MIN = MIN_HIT / Math.min(...COURSES.map((c) => c.h)); // floor: smallest course never drops under 44px
const SCALE_MAX = 1.6;

const GUST_PERIOD = 4.8; // s — spec's 260px/s at the 1200px reference wall
const GUST_WIDTH = 190; // px
const AZIMUTH_PERIOD = 22.0; // s
const AZIMUTH_DEG = 14;
// reduced-motion freeze frame is spec'd at t=7.40s; the composed values below
// (azimuth -6deg, one slate at max gust-lift, one fully read) are that frame
// baked in directly rather than evaluated from the continuous functions, so
// the still is exact and never depends on the (paused) clock.

const LIFT_TARGET = -34; // deg
const LIFT_K = 210;
const LIFT_C = 19;
const GUST_LIFT_K = 1200;
const GUST_LIFT_C = 24;

function isCentreNailed(index: number) {
  return index % 17 < 3;
}

// deterministic 0..1 hash, seeded by index — used for riven banding and gust
// lift amplitude so both are byte-stable across renders without a stored RNG
function hash(seed: number): number {
  let x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
  x -= Math.floor(x);
  return x;
}

function springStep(
  value: number,
  velocity: number,
  target: number,
  k: number,
  c: number,
  dt: number
): [number, number] {
  const accel = -k * (value - target) - c * velocity;
  const nv = velocity + accel * dt;
  return [value + nv * dt, nv];
}

// ---- token colour derivation (5 tokens only, HSL-lightness math) ---------

function hexToRgb(hex: string): [number, number, number] {
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
  if (!m) return [128, 128, 128];
  let h = m[1];
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
  r /= 255;
  g /= 255;
  b /= 255;
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  let h = 0;
  let s = 0;
  const l = (max + min) / 2;
  const d = max - min;
  if (d !== 0) {
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      case r:
        h = (g - b) / d + (g < b ? 6 : 0);
        break;
      case g:
        h = (b - r) / d + 2;
        break;
      default:
        h = (r - g) / d + 4;
    }
    h /= 6;
  }
  return [h, s, l];
}

function hslToRgb(h: number, s: number, l: number): [number, number, number] {
  if (s === 0) {
    const v = Math.round(l * 255);
    return [v, v, v];
  }
  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
  const p = 2 * l - q;
  const hue2rgb = (t0: number) => {
    let t = t0;
    if (t < 0) t += 1;
    if (t > 1) t -= 1;
    if (t < 1 / 6) return p + (q - p) * 6 * t;
    if (t < 1 / 2) return q;
    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
    return p;
  };
  return [
    Math.round(hue2rgb(h + 1 / 3) * 255),
    Math.round(hue2rgb(h) * 255),
    Math.round(hue2rgb(h - 1 / 3) * 255),
  ];
}

function rgbToHex(r: number, g: number, b: number): string {
  const c = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
  return `#${c(r)}${c(g)}${c(b)}`;
}

/** Reuses a token's own hue/saturation but forces a target HSL lightness —
 * every derived colour below still traces back to a token read, never a
 * literal, but section 6's L values are what actually place it. */
function withLightness(hex: string, l: number): string {
  const [r, g, b] = hexToRgb(hex);
  const [h, s] = rgbToHsl(r, g, b);
  return rgbToHex(...hslToRgb(h, s, Math.max(0, Math.min(1, l))));
}

function shiftLightness(hex: string, delta: number): string {
  const [r, g, b] = hexToRgb(hex);
  const [h, s, l] = rgbToHsl(r, g, b);
  return rgbToHex(...hslToRgb(h, s, Math.max(0, Math.min(1, l + delta))));
}

function lightnessOf(hex: string): number {
  const [r, g, b] = hexToRgb(hex);
  return rgbToHsl(r, g, b)[2];
}

interface Tokens {
  face: string;
  margin: string;
  shadow: string;
  edge: string;
  backing: string;
  riven: string;
  ink: string;
  inkMuted: string;
}

function readTokens(): Tokens {
  const cs = getComputedStyle(document.documentElement);
  const bg = cs.getPropertyValue("--background").trim() || "#ffffff";
  const fg = cs.getPropertyValue("--foreground").trim() || "#171717";
  const dark = lightnessOf(bg) < 0.5;

  // Section 6, retuned for card scale. The wall is a mid-value stone that
  // sits BELOW the page in dark and ABOVE the page in light — a dark stone
  // on a white page read as one undifferentiated slab, because every
  // internal step (margin, lap shadow, arris) had to fit under the face's
  // own lightness. Each theme therefore gets its own absolute lightness
  // ladder rather than a shared set of deltas.
  const L = dark
    ? { face: 0.44, margin: 0.53, shadow: 0.19, edge: 0.72, backing: 0.2, ink: 0.08, inkMuted: 0.22 }
    : { face: 0.66, margin: 0.76, shadow: 0.5, edge: 0.9, backing: 0.46, ink: 0.14, inkMuted: 0.32 };

  const face = withLightness(fg, L.face);
  const margin = withLightness(fg, L.margin);
  // the lap shadow carries the whole image in a monochrome wall: measured at
  // -0.10L it was invisible at card scale and the courses read as one slab
  const shadow = withLightness(fg, L.shadow);
  const edge = withLightness(fg, L.edge);
  // the batten behind the wall: the half-slate stagger leaves a gap at the
  // start of every other course, and against the page background those gaps
  // read as missing tiles rather than as the wall carrying on behind
  const backing = withLightness(fg, L.backing);
  const riven = shiftLightness(face, 0.03);
  const ink = withLightness(fg, L.ink);
  const inkMuted = withLightness(fg, L.inkMuted);
  return { face, margin, shadow, edge, backing, riven, ink, inkMuted };
}

// ---- default placeholder content ------------------------------------------

// Illustrative placeholder quotes: deliberately generic, attributed to a role
// rather than to any person or company, so nothing here reads as a real
// customer claim. Swap `items` for your own.
const DEFAULT_QUOTES: { quote: string; name: string; role: string }[] = [
  { quote: "We moved the whole team across in one afternoon.", name: "Engineering lead", role: "platform" },
  { quote: "It replaced four tools we were stitching together.", name: "Product manager", role: "growth" },
  { quote: "The defaults are sensible enough to ship on.", name: "Founder", role: "two-person studio" },
  { quote: "Onboarding takes a morning, not a fortnight.", name: "Head of design", role: "in-house team" },
  { quote: "Nobody argues about which copy is current now.", name: "Operations lead", role: "support" },
  { quote: "Tickets about the old export flow stopped.", name: "Customer success", role: "EMEA" },
  { quote: "It fits how we already work, not the reverse.", name: "Staff engineer", role: "infrastructure" },
  { quote: "The audit trail answered a question we dreaded.", name: "Security lead", role: "compliance" },
  { quote: "Rolling a mistake back is a single click.", name: "Tech lead", role: "web" },
  { quote: "Design and engineering read the same source.", name: "Design engineer", role: "design systems" },
  { quote: "We stopped keeping a spreadsheet nobody trusted.", name: "Analytics lead", role: "data" },
  { quote: "Quiet, fast, and it has never lost our work.", name: "Principal engineer", role: "core services" },
  { quote: "The migration was the boring part of the quarter.", name: "Delivery manager", role: "programmes" },
  { quote: "It is the first internal tool people ask for.", name: "Chief of staff", role: "operations" },
  { quote: "Two years in, we have not outgrown it.", name: "Director of engineering", role: "product" },
  { quote: "Handover notes write themselves now.", name: "Programme lead", role: "delivery" },
  { quote: "Nothing about it has surprised us badly.", name: "Platform architect", role: "reliability" },
  { quote: "The people who resisted it use it most.", name: "Team lead", role: "customer platform" },
];

const DEFAULT_ITEMS: SlateTestimonial[] = DEFAULT_QUOTES.map((q, i) => ({
  id: `slate-${i + 1}`,
  ...q,
}));

interface Slate {
  index: number;
  row: number;
  col: number;
  x: number; // design px, unscaled
  top: number; // design px, unscaled — top of this course's exposed gauge
  h: number;
  gauge: number;
  exposure: number;
  centreNailed: boolean;
}

function layout(items: SlateTestimonial[], columns: number): { slates: Slate[]; naturalW: number; naturalH: number } {
  const cols = Math.max(1, columns);
  const rows = Math.ceil(items.length / cols);
  const courseTop: number[] = [0];
  for (let r = 0; r < rows; r++) courseTop.push(courseTop[r] + COURSES[r % COURSES.length].exposure);

  const slates: Slate[] = items.map((_, i) => {
    const row = Math.floor(i / cols);
    const col = i % cols;
    const course = COURSES[row % COURSES.length];
    const offset = row % 2 === 1 ? COL_OFFSET : 0;
    return {
      index: i,
      row,
      col,
      x: col * SLATE_W + offset,
      top: courseTop[row],
      h: course.h,
      gauge: course.gauge,
      exposure: course.exposure,
      centreNailed: isCentreNailed(i),
    };
  });

  const naturalW = cols * SLATE_W + COL_OFFSET;
  const naturalH = courseTop[rows]; // sum of every course's exposure — the last course's excess is trimmed
  return { slates, naturalW, naturalH };
}

export function SlateGauge({
  items = DEFAULT_ITEMS,
  columns = 3,
  heading = "What teams say",
  className = "",
}: SlateGaugeProps) {
  const hostRef = useRef<HTMLDivElement>(null);
  const wallRef = useRef<HTMLDivElement>(null);
  const slateFaceRefs = useRef<Record<number, HTMLElement | null>>({});
  const rivenRefs = useRef<Record<number, HTMLDivElement | null>>({});
  const [liftedIndex, setLiftedIndex] = useState<number | null>(null);
  const [scale, setScale] = useState(1);
  const liftedRef = useRef<number | null>(null);
  liftedRef.current = liftedIndex;

  const { slates, naturalW, naturalH } = useMemo(() => layout(items, columns), [items, columns]);

  // reduced-motion picks one representative slate to hold lifted and one
  // centre-nailed slate near the frozen gust to hold mid-clatter, so the
  // single composed frame shows every state the wall has at once (spec §5)
  const staticPicks = useMemo(() => {
    const gustCol = Math.min(1, Math.max(0, columns - 1));
    const lifted = Math.min(2, slates.length - 1);
    const near = slates.find((s) => s.centreNailed && s.col === gustCol) ?? slates.find((s) => s.centreNailed);
    return { lifted, microLift: near?.index ?? null };
  }, [slates, columns]);

  const rivenBg = useMemo(() => {
    // deterministic per-slate band pattern (spacing only — colours are
    // re-applied on token read) computed once so it is byte-stable forever
    const maps: Record<number, { start: number; end: number }[]> = {};
    for (const s of slates) {
      const bands: { start: number; end: number }[] = [];
      let y = 0;
      let seed = s.index * 97 + 11;
      while (y < s.h) {
        const w = 3 + hash(seed) * 6; // 3-9px
        bands.push({ start: y, end: Math.min(s.h, y + w) });
        y += w;
        seed += 1;
      }
      maps[s.index] = bands;
    }
    return maps;
  }, [slates]);

  // ---- token read, geometry fit, and the whole idle loop -----------------
  useLayoutEffect(() => {
    const host = hostRef.current;
    const wall = wallRef.current;
    if (!host || !wall) return;

    // token read happens before anything paints — see the class comment
    let tokens = readTokens();
    const applyTokens = () => {
      wall.style.setProperty("--slate-face", tokens.face);
      wall.style.setProperty("--slate-margin", tokens.margin);
      wall.style.setProperty("--slate-shadow-color", tokens.shadow);
      wall.style.setProperty("--slate-edge", tokens.edge);
      wall.style.setProperty("--slate-backing", tokens.backing);
      wall.style.setProperty("--slate-riven", tokens.riven);
      wall.style.setProperty("--slate-ink", tokens.ink);
      wall.style.setProperty("--slate-ink-muted", tokens.inkMuted);
      // repaint riven band gradients with the fresh colour pair — percentage
      // stops (of the slate's own full height) so the pattern never needs to
      // be re-scaled when the fit scale or the lift height changes
      for (const s of slates) {
        const el = rivenRefs.current[s.index];
        if (!el) continue;
        const stops = rivenBg[s.index]
          .map((b, i) => {
            // ±0.03L fracture banding (section 6) — a texture on the stone
            // itself, distinct from the sky-lit margin tint layered above it
            const c = i % 2 === 0 ? tokens.face : tokens.riven;
            return `${c} ${(b.start / s.h) * 100}%, ${c} ${(b.end / s.h) * 100}%`;
          })
          .join(", ");
        el.style.backgroundImage = `linear-gradient(to bottom, ${stops})`;
      }
    };
    applyTokens();

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const fit = () => {
      const rect = host.getBoundingClientRect();
      if (rect.width < 4 || rect.height < 4) return;
      const s = Math.max(SCALE_MIN, Math.min(SCALE_MAX, Math.min(rect.width / naturalW, rect.height / naturalH)));
      setScale(s);
    };
    fit();
    const ro = new ResizeObserver(fit);
    ro.observe(host);

    if (reduced) {
      // one composed still frame, byte-stable: no loop, no listeners
      wall.style.setProperty("--wall-azimuth", "-6");
      if (staticPicks.microLift != null) {
        const el = slateFaceRefs.current[staticPicks.microLift];
        if (el) el.style.setProperty("--gust-lift", "5");
      }
      setLiftedIndex(staticPicks.lifted);
      const liftedEl = slateFaceRefs.current[staticPicks.lifted];
      if (liftedEl) liftedEl.style.transform = `rotate(${LIFT_TARGET}deg)`;
      return () => ro.disconnect();
    }

    // ---- continuous idle loop: gust field + light azimuth + micro-lifts --
    let raf = 0;
    let running = false;
    let last = performance.now();
    let t = 0;
    const liftState = new Map<number, { v: number; vel: number; target: number }>();
    let readLift = { v: 0, vel: 0 };

    // the slate currently returning after release is tracked separately from
    // the one actively held, since liftedIndex can flip to null the instant
    // a pointer leaves — the spring still needs a face to write to
    let lastHeld: number | null = null;
    const tick = (now: number) => {
      raf = requestAnimationFrame(tick);
      const dt = Math.min(0.05, Math.max(0, (now - last) / 1000));
      last = now;
      t += dt;

      const azimuth = AZIMUTH_DEG * Math.cos((2 * Math.PI * t) / AZIMUTH_PERIOD);
      wall.style.setProperty("--wall-azimuth", azimuth.toFixed(2));

      // gust math stays entirely in natural (unscaled) design units, the same
      // space slate.x already lives in — the fit `scale` only ever touches
      // the JSX render, never this comparison
      const span = naturalW + GUST_WIDTH * 2;
      const frac = (t % GUST_PERIOD) / GUST_PERIOD;
      const gustX = -GUST_WIDTH + frac * span;

      for (const s of slates) {
        if (!s.centreNailed) continue;
        const el = slateFaceRefs.current[s.index];
        if (!el) continue;
        const centerX = s.x + SLATE_W / 2;
        const inGust = Math.abs(centerX - gustX) < GUST_WIDTH / 2;
        const amp = 2.5 + hash(s.index * 31 + 7) * 2.5;
        let st = liftState.get(s.index);
        if (!st) {
          st = { v: 0, vel: 0, target: 0 };
          liftState.set(s.index, st);
        }
        st.target = inGust ? amp : 0;
        const [nv, nvel] = springStep(st.v, st.vel, st.target, GUST_LIFT_K, GUST_LIFT_C, dt);
        st.v = nv;
        st.vel = nvel;
        el.style.setProperty("--gust-lift", st.v.toFixed(2));
      }

      const held = liftedRef.current;
      const activeIndex = held ?? lastHeld;
      if (held != null) lastHeld = held;
      if (activeIndex != null) {
        const el = slateFaceRefs.current[activeIndex];
        const target = held != null ? LIFT_TARGET : 0;
        const [nv, nvel] = springStep(readLift.v, readLift.vel, target, LIFT_K, LIFT_C, dt);
        readLift = { v: nv, vel: nvel };
        if (el) el.style.transform = nv === 0 ? "" : `rotate(${nv.toFixed(2)}deg)`;
        if (held == null && Math.abs(nv) < 0.02 && Math.abs(nvel) < 0.02) {
          if (el) el.style.transform = "";
          lastHeld = null;
          readLift = { v: 0, vel: 0 };
        }
      }
    };
    running = true;
    raf = requestAnimationFrame(tick);

    const io = new IntersectionObserver(
      (entries) => {
        const visible = entries.some((e) => e.isIntersecting);
        if (visible && !running) {
          running = true;
          last = performance.now();
          raf = requestAnimationFrame(tick);
        } else if (!visible && running) {
          running = false;
          cancelAnimationFrame(raf);
        }
      },
      { threshold: 0 }
    );
    io.observe(host);

    const onVis = () => {
      if (document.hidden && running) {
        running = false;
        cancelAnimationFrame(raf);
      } else if (!document.hidden && !running) {
        running = true;
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }
    };
    document.addEventListener("visibilitychange", onVis);

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

    return () => {
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      mo.disconnect();
      cancelAnimationFrame(raf);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [slates, naturalW, naturalH, rivenBg, staticPicks]);

  const toggle = (index: number) => {
    setLiftedIndex((prev) => (prev === index ? null : index));
  };

  return (
    <div ref={hostRef} className={`relative flex h-full w-full flex-col items-center justify-center gap-4 overflow-hidden bg-background p-4 ${className}`}>
      <p className="font-mono text-xs tracking-widest text-ns-muted">{heading}</p>
      <div
        ref={wallRef}
        className="relative shrink-0"
        // the eaves rail occupies its own band below the last course rather
        // than overlaying it — laid on top it clipped the foot course's last
        // line, which is the exact fault it exists to prevent
        style={{ width: naturalW * scale, height: naturalH * scale + Math.max(4, 6 * scale) }}
      >
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 z-0 rounded-[1px]"
          style={{ backgroundColor: "var(--slate-backing)" }}
        />
        {/* The eaves. Every interior course is cut off by the slate lapping
            over it, which is what reads as overlap; the bottom course has
            nothing above it, so without a rail its cut edge reads as
            truncated text instead of as the foot of the wall. */}
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-x-0 z-[1001]"
          style={{
            bottom: 0,
            height: Math.max(4, 6 * scale),
            backgroundColor: "var(--slate-shadow-color)",
            boxShadow: [
              `inset 0 ${Math.max(1, scale)}px 0 0 var(--slate-edge)`,
              `0 ${-3 * scale}px ${8 * scale}px ${-2 * scale}px var(--slate-shadow-color)`,
            ].join(", "),
          }}
        />
        {slates.map((s) => {
          const isLifted = liftedIndex === s.index;
          const padAbove = Math.max(0, MIN_HIT - s.exposure * scale);
          return (
            <button
              key={items[s.index].id}
              type="button"
              aria-expanded={isLifted}
              data-expanded={isLifted}
              data-slate-lift
              aria-label={`${isLifted ? "Collapse" : "Read"} testimonial from ${items[s.index].name}`}
              onClick={() => toggle(s.index)}
              onBlur={() => {
                if (liftedRef.current === s.index) setLiftedIndex(null);
              }}
              onKeyDown={(e) => {
                if (e.key === "Escape" && liftedRef.current === s.index) {
                  setLiftedIndex(null);
                  (e.currentTarget as HTMLElement).blur();
                }
              }}
              className="group absolute cursor-pointer appearance-none border-0 bg-transparent p-0 text-left"
              style={{
                left: s.x * scale,
                top: s.top * scale - padAbove,
                width: SLATE_W * scale,
                height: s.exposure * scale + padAbove,
                // resting bands never overlap (each course occupies exactly
                // its own gauge, edge to edge), so z only has to win once a
                // slate is lifted and needs to paint over the course below it
                zIndex: isLifted ? 1000 : 1,
              }}
            >
              <article
                ref={(el) => {
                  slateFaceRefs.current[s.index] = el;
                }}
                data-slate-face
                className="absolute overflow-hidden rounded-[1px] border-0 bg-[var(--slate-face)] group-focus-visible:ring-2 group-focus-visible:ring-ns-accent group-focus-visible:ring-offset-1"
                style={{
                  top: padAbove,
                  left: 0,
                  width: SLATE_W * scale,
                  // at rest the face IS its gauge — only the exposed margin
                  // is ever painted; lifting grows it to the slate's real
                  // height, which is what reveals the rest of the quote
                  height: (isLifted ? s.h : s.exposure) * scale,
                  transformOrigin: `50% ${NAIL_Y * scale}px`,
                  // no colour fallback here on purpose: before the token read
                  // commits (useLayoutEffect, pre-paint) --slate-shadow-color
                  // is unset, which makes the whole box-shadow invalid and
                  // therefore simply not painted, rather than painted with a
                  // literal placeholder colour
                  // one cast shadow onto the course below (this is what makes
                  // the lap read as a slate sitting OVER its neighbour rather
                  // than as text truncated at a hard edge), one inset seam,
                  // and a sky-lit top arris. All three swing with the single
                  // root-level azimuth property.
                  boxShadow: [
                    `0 ${-1 * Math.max(1, scale)}px 0 0 var(--slate-shadow-color)`,
                    `0 ${-3 * scale}px ${7 * scale}px ${-1 * scale}px var(--slate-shadow-color)`,
                    `inset calc(var(--wall-azimuth, 0) * 0.4px) ${3 * scale}px ${5 * scale}px ${-1 * scale}px var(--slate-shadow-color)`,
                    `inset 0 ${Math.max(1, scale)}px 0 0 var(--slate-edge)`,
                  ].join(", "),
                  translate: `0 calc(var(--gust-lift, 0) * -1px)`,
                }}
              >
                {/* riven texture: a separate layer fixed at the slate's FULL
                    design height regardless of the article's current (gauge
                    or lifted) height, so its percentage-based band stops
                    always land at the same physical position on the stone —
                    the article's own overflow-hidden is what crops it to
                    whichever margin is currently showing */}
                <div
                  ref={(el) => {
                    rivenRefs.current[s.index] = el;
                  }}
                  aria-hidden="true"
                  className="absolute inset-x-0 top-0"
                  style={{ height: s.h * scale }}
                />
                {/* the exposed margin is sky-lit relative to the rest of the
                    stone (section 6, +0.06/+0.05L) — a fixed-height tint over
                    exactly the gauge strip, independent of the article's own
                    current (gauge or lifted) height */}
                <div
                  aria-hidden="true"
                  className="absolute inset-x-0 top-0 bg-[var(--slate-margin)] opacity-40"
                  style={{ height: s.exposure * scale }}
                />
                <div
                  className="relative flex h-full flex-col"
                  style={{
                    color: "var(--slate-ink)",
                    padding: `${PAD_Y * scale}px ${8 * scale}px`,
                    // type is scaled with the wall, not left at a fixed pixel
                    // size — a fixed 10px against a gauge that grows with the
                    // fit scale is what left a half line at every lap
                    fontSize: `${FONT_PX * scale}px`,
                    lineHeight: LINE_RATIO,
                  }}
                >
                  {/* always full text in the DOM, never display:none — the
                      gauge-height article above is what occludes it visually */}
                  <blockquote className="m-0">&ldquo;{items[s.index].quote}&rdquo;</blockquote>
                  <footer
                    className="m-0 font-medium"
                    style={{ color: "var(--slate-ink-muted)", fontSize: `${FONT_PX * scale}px` }}
                  >
                    {items[s.index].name} &middot; {items[s.index].role}
                  </footer>
                </div>
              </article>
            </button>
          );
        })}
      </div>
    </div>
  );
}

SlateGauge.displayName = "SlateGauge";

export default SlateGauge;

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

Use when Pick slate-gauge for a testimonial wall where reading is itself the interaction and the resting state is deliberately partial — a lapped lattice of slates, each showing only its slater-computed gauge margin until clicked. Pick testimonial-wall-reflow instead when every quote should be legible at rest and the interesting motion is the OTHER cards reflowing around one that expands; pick a static quote grid when nothing ever needs to occlude or expand at all.

Install

npx shadcn add https://design.helpmarq.com/r/slate-gauge.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 <SlateGauge items? columns? heading? className?> as a DOM/CSS testimonial wall modelled on double-lap slating, no canvas. GEOMETRY: five diminishing courses, heights [132,120,110,101,94]px at slate width 168px, lap 44px, head allowance 8px; each course's gauge (its exposed margin) is computed, never chosen: gauge = (h - 44 - 8) / 2, giving [40,34,29,24.5,21]px. Testimonials fill row-major (course 1 left-to-right, then course 2, ...) into `columns` slates per course (default 2), cycling back through the five courses if there are more than five rows. Alternate courses are offset laterally by 84px (half a slate) to break the side lap. AT REST every slate's rendered face is exactly its own gauge tall — courses are laid edge to edge with zero overlap, so nothing needs a clip-path to hide the covered nine-tenths of a slate: it is simply never painted at that height. The quote text is always fully in the DOM inside a <button aria-expanded> wrapping an <article> — never display:none — so a screen reader, Ctrl-F and text selection reach the whole sentence regardless of what height is currently rendered; only the SHORT box clips it visually. READING: clicking a slate (or Enter/Space on its button) grows that one article from gauge to its true height h and raises it to z-index 1000, which is the only overlap this layout ever has, while it also rotates -34deg about a nail line 18px below its head (transform-origin) on an underdamped spring integrated in an rAF loop with k=210, c=19 (semi-implicit Euler, mass 1) — the spring's own underdamped ratio (c/c_crit ≈ 0.66) is what produces the single ~3deg overshoot as an emergent property rather than a hand-authored keyframe, and relaxing the SAME spring back to target 0 on release/Escape/blur is what produces the return clatter, so lift and return are one mechanism read in two directions. Only one slate is ever lifted; lifting a second sets both aria-expanded values in the same state commit, and Tab order is DOM order (course 1 left-to-right, then course 2, ...), not visual stacking order. HIT TARGET: each slate's button is padded upward (never downward, which would intrude on the course it's about to cover) to a 44px minimum height when its gauge is smaller, via fully transparent extra box — the painted <article> inside is untouched, so the drawn lap never changes. ALIVE AT REST, two unforced processes, neither ever touching the wall's own layout: (1) a 190px-wide gust sweeps left to right every 4.8s (natural, unscaled design units); a deterministic 3-in-17 subset of slates (index % 17 < 3) is centre-nailed, and whenever a centre-nailed slate's tail sits inside the gust it lifts 2.5-5px (per-slate deterministic amplitude via a sine-based hash of its index) on a second, snappier underdamped spring (k=1200, c=24) applied as a CSS `translate` (a separate transform channel from the read-rotation's `transform: rotate()`, so both apply independently on the same element without fighting). (2) a light azimuth swings ±14deg on a 22s cosine, written ONCE per frame as a single `--wall-azimuth` custom property on the wall root and read by every slate's own `box-shadow: inset calc(var(--wall-azimuth) * 0.4px) ...` — one style write drives the whole wall's cast-shadow relief inverting together, rather than touching N DOM nodes per frame. The azimuth NEVER changes any slate's own face luminance, only the cast shadow's horizontal offset and hence which side it falls on. `prefers-reduced-motion` skips the rAF loop entirely and instead composes ONE fixed, byte-stable frame: azimuth -6deg, one centre-nailed slate held at --gust-lift:5 (its max), and one representative slate forced into the lifted state at exactly -34deg with no spring residue — every state the component has, shown at once, with no timers running afterward. TOKENS AND COLOUR: only --background, --foreground, --ns-muted, --border (unused — no separator lines anywhere on the wall) and --ns-accent (interaction chrome only, the read-button focus ring) are read, via getComputedStyle on documentElement, re-read on a MutationObserver watching its class. Every derived colour keeps a token's own hue/saturation and only overrides HSL lightness: slate face = --foreground forced to L 0.34 (light theme) / 0.52 (dark); the exposed margin is a separate tint LAYER (not a different base colour) at the same hue, +0.06L light / +0.05L dark, drawn only over the top `gauge` px so the sky-lit strip reads lighter regardless of whether the slate is at rest or lifted; the tail shadow (the box-shadow colour) is the margin shifted -0.12L light / -0.10L dark; the riven fracture texture is a percentage-stop background gradient alternating face and face+0.03L in 3-9px bands seeded deterministically per slate index (byte-stable, never animated), rendered on a layer pinned to the slate's FULL design height regardless of its current gauge/lifted height so the pattern never needs re-scaling — only the enclosing article's overflow-hidden crops it. Quote ink is --foreground in light theme, --background in dark (darker than the slate face in both, no glow inversion). FIT: the component measures its host's width AND height (ResizeObserver) and scales all geometry by min(hostW/naturalW, hostH/naturalH), clamped so the smallest course never renders under 44px (scale floor) and never above 1.4x — deriving from the smaller dimension is what keeps a short, wide card from producing an unreadably short wall. No box-shadow colour literal fallback is given anywhere (an unset custom property makes the whole box-shadow declaration invalid and therefore simply unpainted, which is the desired pre-token-read state, rather than painting a placeholder hex).

Props

PropTypeDefaultDescription
items?SlateTestimonial[]DEFAULT_ITEMSTestimonials, filled row-major (course 1 left-to-right, then course 2, ...).
columns?number3Slates per course. @default 3
heading?string"What teams say"Heading rendered above the wall.
className?string——