Skip to main content

ns-ui

Blast Hole Delay Sequence

A bench-blast shot pattern rendered as a grid of charged holes that fires row by row on a delay wave rather than all at once, each row flashing to peak brightness and settling to a spent, darkened crater before the wave reaches the next row.

Use when a status/loader-family surface where many small units progress toward one event IN A FIXED ORDER and the order itself is the thing worth watching — a shot pattern's delay wave sweeping row by row across a grid, settling each row to a spent state before the next fires. Pick loader-braille instead when the loader needs a compact inline glyph with a genuine indeterminate/determinate progress value rather than a fixed decorative firing order, or status-glyph-cadence when a single inline status needs to communicate discrete named states (working/blocked/done) rather than a many-cell sequential sweep.

Install

npx shadcn add https://design.helpmarq.com/r/blast-hole-delay-sequence.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/blast-hole-delay-sequence/component.tsx
"use client";

// ---------------------------------------------------------------------------
// BlastRoundPattern — a bench-blast shot pattern: a grid of charged holes
// that fires row by row on a fixed delay wave, not all at once. Real
// electronic detonators run 25-50ms between rows (far too fast to read as
// a sequence), so the row cadence here is deliberately decoupled from that
// real rate and rendered 36x slower at 900ms/row — the real number is
// documented, the animation is the legible one.
//
// Each cell is a div whose crater is painted from two CSS custom properties,
// --fire (0 unfired -> 1 peak flash) and --spent (0 fresh -> 1 spent-dark),
// set directly on the row wrapper each frame (never React state) and read
// by every cell in that row through ordinary CSS inheritance — one rAF
// write per row, not per cell. Every colour is `var(--foreground)` /
// `var(--ns-muted)` mixed against itself or scaled with `filter:brightness`,
// so direction (darker/lighter) tracks whichever theme is active without
// ever branching on it in JS.
// ---------------------------------------------------------------------------

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

const MIN_GRID = 6;
const MAX_GRID = 9;
const TARGET_CELL_PX = 32;

const ROW_DELAY_MS = 900; // decoupled render rate; real electronic-det. delay is 25-50ms/row
const RISE_MS = 80;
const HOLD_MS = 120;
const DECAY_MS = 600;
const FLASH_MS = RISE_MS + HOLD_MS + DECAY_MS; // 800ms — a row is fully spent 100ms before the next fires
const CLEARED_PAUSE_MS = 2000;
const RECHARGE_PER_ROW_MS = 250;

// loop-start phase: mounts mid-hold on row 0 so t0 already shows motion
const START_PHASE_MS = RISE_MS + HOLD_MS / 2;

const SPENT_BRIGHTNESS_FLOOR = 0.38; // filter:brightness() applied to muted — always darker, both themes

function computeGridSize(minDim: number): number {
  const n = Math.round(minDim / TARGET_CELL_PX);
  return Math.min(MAX_GRID, Math.max(MIN_GRID, n));
}

// ease-out for the rise, ease-in for the decay — fast punch, slow settle
function easeOutCubic(t: number): number {
  const u = 1 - t;
  return 1 - u * u * u;
}
function easeInCubic(t: number): number {
  return t * t * t;
}

// Per-row (--fire, --spent) at a given time-into-pattern (can be negative
// before the row's own fireStart, and keeps returning "spent" indefinitely
// after the row finishes until the recharge sweep resets it explicitly).
function rowFireState(tSinceRowStart: number): { fire: number; spent: number } {
  if (tSinceRowStart < 0) return { fire: 0, spent: 0 };
  if (tSinceRowStart < RISE_MS) {
    return { fire: easeOutCubic(tSinceRowStart / RISE_MS), spent: 0 };
  }
  if (tSinceRowStart < RISE_MS + HOLD_MS) {
    return { fire: 1, spent: 0 };
  }
  if (tSinceRowStart < FLASH_MS) {
    const d = easeInCubic((tSinceRowStart - RISE_MS - HOLD_MS) / DECAY_MS);
    return { fire: 1 - d, spent: d };
  }
  return { fire: 0, spent: 1 };
}

export interface BlastRoundPatternProps {
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** accessible label for the pattern */
  "aria-label"?: string;
}

export function BlastRoundPattern({
  className = "",
  "aria-label": ariaLabel = "Blast round delay sequence",
}: BlastRoundPatternProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
  const [gridSize, setGridSize] = useState(MIN_GRID);

  // grid size tracks the container's smaller dimension, so the pattern
  // reads as 6x6 at card scale and grows toward 9x9 on a larger card
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const ro = new ResizeObserver((entries) => {
      const entry = entries[0];
      if (!entry) return;
      const box = entry.contentBoxSize?.[0];
      const w = box ? box.inlineSize : entry.contentRect.width;
      const h = box ? box.blockSize : entry.contentRect.height;
      setGridSize(computeGridSize(Math.min(w, h)));
    });
    ro.observe(root);
    return () => ro.disconnect();
  }, []);

  useEffect(() => {
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const rowEls = rowRefs.current.slice(0, gridSize);

    const cycleFireMs = gridSize * ROW_DELAY_MS;
    const cycleMs = cycleFireMs + CLEARED_PAUSE_MS + gridSize * RECHARGE_PER_ROW_MS;

    const paint = (phase: number) => {
      for (let r = 0; r < rowEls.length; r++) {
        const el = rowEls[r];
        if (!el) continue;

        const rowFireStart = r * ROW_DELAY_MS;
        const rechargeStart = cycleFireMs + CLEARED_PAUSE_MS + r * RECHARGE_PER_ROW_MS;

        let fire = 0;
        let spent: number;

        if (phase < cycleFireMs + CLEARED_PAUSE_MS) {
          // firing phase + the cleared pause that follows it
          const st = rowFireState(phase - rowFireStart);
          fire = st.fire;
          spent = st.spent;
        } else if (phase < rechargeStart) {
          // row hasn't reached its turn in the recharge sweep yet
          spent = 1;
        } else if (phase < rechargeStart + RECHARGE_PER_ROW_MS) {
          // quiet reset, no flash: spent eases back to 0 over its 250ms slot
          const t = (phase - rechargeStart) / RECHARGE_PER_ROW_MS;
          spent = 1 - t;
        } else {
          spent = 0;
        }

        el.style.setProperty("--fire", fire.toFixed(4));
        el.style.setProperty("--spent", spent.toFixed(4));
      }
    };

    if (reduced) {
      // frozen freeze-frame: rows before "4" spent-dark, row 4 (index 3) at
      // peak flash, the rest still unfired-dim — the one frame that shows
      // all three states at once, which no point in the live loop pins down
      const frozenRow = Math.min(3, gridSize - 1);
      for (let r = 0; r < rowEls.length; r++) {
        const el = rowEls[r];
        if (!el) continue;
        if (r < frozenRow) {
          el.style.setProperty("--fire", "0");
          el.style.setProperty("--spent", "1");
        } else if (r === frozenRow) {
          el.style.setProperty("--fire", "1");
          el.style.setProperty("--spent", "0");
        } else {
          el.style.setProperty("--fire", "0");
          el.style.setProperty("--spent", "0");
        }
      }
      return;
    }

    let raf = 0;
    let visible = true;
    const start = performance.now() - START_PHASE_MS;

    const loop = (now: number) => {
      if (visible) {
        const phase = (now - start) % cycleMs;
        paint(phase < 0 ? phase + cycleMs : phase);
      }
      raf = requestAnimationFrame(loop);
    };

    const io = new IntersectionObserver(
      (entries) => {
        visible = entries[0]?.isIntersecting ?? true;
      },
      { threshold: 0 }
    );
    const root = rootRef.current;
    if (root) io.observe(root);

    raf = requestAnimationFrame(loop);
    return () => {
      cancelAnimationFrame(raf);
      io.disconnect();
    };
  }, [gridSize]);

  const cells = Array.from({ length: gridSize });

  return (
    <div
      ref={rootRef}
      role="img"
      aria-label={ariaLabel}
      className={`relative flex items-center justify-center overflow-hidden bg-background p-[6%] ${className}`}
    >
      <style>{`
.ns-bhds-grid{
  display: grid;
  gap: 12%;
  width: 100%;
  height: 100%;
  aspect-ratio: 1 / 1;
  max-width: 100%;
  max-height: 100%;
}
.ns-bhds-row{
  display: contents;
}
.ns-bhds-cell{
  position: relative;
  border-radius: 999px;
  background-color: color-mix(
    in oklch,
    var(--ns-muted),
    var(--foreground) calc(var(--fire, 0) * 100%)
  );
  filter: brightness(calc(1 - var(--spent, 0) * ${1 - SPENT_BRIGHTNESS_FLOOR}));
  box-shadow: 0 0 calc(var(--fire, 0) * 18%)
    color-mix(in srgb, var(--foreground) calc(var(--fire, 0) * 60%), transparent);
  transition: filter 60ms linear;
}
.ns-bhds-cell::after{
  content: "";
  position: absolute;
  inset: 22%;
  border-radius: 999px;
  background: color-mix(in srgb, var(--background) calc(38% - var(--fire, 0) * 30%), transparent);
}
`}</style>
      <div
        className="ns-bhds-grid"
        style={{ gridTemplateColumns: `repeat(${gridSize}, 1fr)`, gridTemplateRows: `repeat(${gridSize}, 1fr)` }}
      >
        {cells.map((_, r) => (
          <div
            key={r}
            ref={(el) => {
              rowRefs.current[r] = el;
            }}
            className="ns-bhds-row"
            style={{ gridRow: r + 1 }}
          >
            {cells.map((__, c) => (
              <div key={c} className="ns-bhds-cell" style={{ gridRow: r + 1, gridColumn: c + 1 }} />
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}
Build spec

A grid of holes (6x6 at card scale, growing up to 9x9 as the container's smaller dimension grows, cell size held near 24-40px by computeGridSize(minDim) = clamp(round(minDim/32), 6, 9), tracked live via a ResizeObserver on the root) that fires in a fixed row-by-row sequence, mirroring how a bench-blast round is wired: rows detonate in order, each tens of milliseconds after the last, so fragmented rock has somewhere to move into. Real electronic detonators run 25-50ms between rows, which is far too fast to read as a sequence, so the row cadence is deliberately decoupled from that real rate: rendered at 900ms/row (documented 36x real-time slowdown), with a full pattern (6 rows) taking 5.4s to fire. Within a row every hole fires together (matches real practice — delay is row-to-row, not hole-to-hole — and satisfies the 'transition shows departure and arrival' rule, since a whole row lighting up together reads as one legible event rather than a blink). Each row owns two CSS custom properties, --fire (0 unfired -> 1 peak) and --spent (0 fresh -> 1 spent-dark), computed once per row per animation frame from a single rAF loop and written directly via el.style.setProperty on a `display:contents` row wrapper (never React state per frame) — every hole cell in that row inherits both through ordinary CSS custom-property inheritance, so a 9x9 pattern costs 9 writes/frame, not 81. A row's flash rises to peak over 80ms (ease-out-cubic), holds 120ms, then decays to spent over 600ms (ease-in-cubic) — asymmetric fast-rise/slow-settle matching how a flash reads against a rock face — after which the full pattern holds fully spent (uniformly dark) for a 2s cleared pause, then a 1.5s recharge sweep resets rows to unfired one at a time (250ms/row, --spent easing back to 0, --fire never leaving 0 — a quiet reload, not part of the mechanic proper) before the cycle restarts; total loop ~8.9s. The loop's start phase is offset mid-hold on row 0 (not the pattern's literal t=0) so mount already shows a mid-flash, not a static grid. Colour: a hole's fill is color-mix(in oklch, var(--ns-muted), var(--foreground) calc(var(--fire) * 100%)) — unfired sits at --ns-muted, peak flash at --foreground, with a brief glow via a --foreground box-shadow scaled by --fire for overshoot without ever touching a literal white. The spent-dark state is filter:brightness(calc(1 - var(--spent) * 0.62)) applied to that same muted-based fill: a brightness scalar always darkens in the same direction regardless of which theme's --ns-muted it is scaling, so in light theme spent reliably reads darker than --ns-muted (the harder case, checked first) without any theme branch in JS, and in dark theme it settles even closer to --background. `--border` is never used as a fill. Reduced motion freezes on rows before index 3 spent-dark, row index 3 (the 4th row) at peak flash, and the remaining rows unfired-dim — the single frame that shows unfired/firing/spent simultaneously, which no frame in the live loop pins down as cleanly. The whole grid is one `role=img aria-label` element; individual cells are decorative and carry no ARIA. `autoplay: none` because the pattern runs on its own internal clock with zero pointer/scroll/press input — there is nothing for a synthetic-input driver to trigger. The rAF loop pauses via an IntersectionObserver when the root scrolls offscreen and resumes cleanly without a stall, and both the rAF and the two observers (Resize + Intersection) are torn down on unmount. Zero dependencies, DOM+CSS only, no canvas.

Props

PropTypeDefaultDescription
className?stringextra classes merged onto the rendered root element
aria-label?string"Blast round delay sequence"accessible label for the pattern