Skip to main content

ns-ui

Shutter Telegraph Board

An ambient multi-stage status board of six louvred shutters, modelled on the Admiralty six-shutter telegraph: each shutter independently flips edge-on (open, near-invisible) or face-on (closed, a solid opaque square) in a staggered cascade that forms a new symbol every couple of seconds.

Use when an ambient loader or decorative multi-stage status indicator for a card that wants a grid-based visual identity rather than a dot ring or spinner — six independent shutters flipping between open/closed states to spell one of 8 fixed symbols in a staggered cascade; pick status-glyph-cadence instead when the indicator needs to sit inline at 20-64px beside a text label and encode a small, meaningful set of real application states (working/searching/blocked/etc.) through motion cadence, since shutter-telegraph-board's 8 symbols are a fixed decorative sequence with no state mapping and it needs real card-scale room (shutters compress unreadably below ~100px card height).

Install

npx shadcn add https://design.helpmarq.com/r/shutter-telegraph-board.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/shutter-telegraph-board/component.tsx
"use client";

// ShutterTelegraphBoard — an ambient multi-stage status board modelled on the
// Admiralty six-shutter telegraph (Murray's telegraph, England 1795-1816): a
// 2x3 board of louvred shutters, each independently flipped edge-on (nearly
// invisible, background shows through) or face-on (a solid opaque square),
// read hilltop to hilltop through a telescope. Each of the 63 open/closed
// combinations encoded a number or codebook phrase.
//
// Pure DOM/CSS, no canvas. Each shutter is a small stack of two divs: a
// fixed 1px --foreground-at-25%-opacity frame (the shutter's edge, visible
// once the panel in front of it rotates out of the way) and an opaque
// --foreground panel driven purely by `transform: perspective(...)
// rotateY(deg)` between 0deg (face-on/closed) and 90deg (edge-on/open) — the
// perspective projection itself is what visually collapses the panel to a
// near-invisible sliver, no opacity fade involved. Every 2s a new symbol
// (one of 8 fixed 6-bit patterns) is applied; the six shutters that need to
// flip each get a 60ms-staggered transitionDelay in reading order
// (left-to-right, top-to-bottom) so a symbol change reads as a wave
// crossing the board rather than a simultaneous snap, each individual flip
// taking 320ms ease-in-out. Colours are plain `var(--foreground)` in inline
// styles — pure DOM with no per-pixel colour math, so (as with the shipped
// flag-hoist-run) there is nothing for getComputedStyle/MutationObserver to
// do that the CSS cascade doesn't already do for free on a theme switch.

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

const COLS = 2;
const ROWS = 3;
const CELL_COUNT = COLS * ROWS;

const FLIP_MS = 320;
const STAGGER_MS = 60;
const DWELL_MS = 1700;
const SYMBOL_INTERVAL_MS = DWELL_MS + (CELL_COUNT - 1) * STAGGER_MS; // ~2000ms
const PERSPECTIVE_PX = 600;

// 8 fixed 6-bit symbols, reading order (row-major, top-to-bottom,
// left-to-right within a row): true = open (edge-on), false = closed
// (face-on). Index 4 is an exact checkerboard — 3 open / 3 closed, the
// maximum structural contrast in the set — reserved as the reduced-motion
// freeze frame. The set deliberately also contains the degenerate
// all-closed (0) and all-open (5) symbols elsewhere in the loop, which is
// exactly why those two are never chosen as the static frame.
const SYMBOLS: readonly boolean[][] = [
  [false, false, false, false, false, false],
  [true, true, false, false, true, false],
  [false, true, true, false, true, true],
  [true, false, true, true, false, false],
  [true, false, false, true, true, false], // checkerboard — reduced-motion frame
  [true, true, true, true, true, true],
  [false, true, false, true, false, true],
  [true, false, true, false, true, false],
];

const STATIC_SYMBOL_INDEX = 4;
const START_SYMBOL_INDEX = 2; // a mixed, non-degenerate t0

export interface ShutterTelegraphBoardProps {
  /** accessible label for the ambient board */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function ShutterTelegraphBoard({
  label = "Signal board",
  className = "",
}: ShutterTelegraphBoardProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const gridRef = useRef<HTMLDivElement>(null);
  const [cellSize, setCellSize] = useState(0);
  const [reduced, setReduced] = useState(false);
  // matches SSR's default (reduced motion is unknown server-side); flipped
  // to STATIC_SYMBOL_INDEX synchronously in the reduced-motion effect below
  // before any timer would otherwise fire, so no flash of the wrong symbol.
  const [symbolIndex, setSymbolIndex] = useState(START_SYMBOL_INDEX);

  // -- reduced motion: pin to the checkerboard symbol, never advance. ----
  useEffect(() => {
    const isReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    setReduced(isReduced);
    if (isReduced) setSymbolIndex(STATIC_SYMBOL_INDEX);
  }, []);

  // -- geometry: square shutters sized from the container's smaller
  // dimension, so the board reads at card scale on any card. -------------
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const ro = new ResizeObserver((entries) => {
      const rect = entries[0]?.contentRect;
      if (!rect) return;
      const minDim = Math.min(rect.width, rect.height);
      setCellSize(minDim / 4);
    });
    ro.observe(root);
    return () => ro.disconnect();
  }, []);

  // -- the loop: a new symbol every SYMBOL_INTERVAL_MS, unbounded, 8-symbol
  // repeat (~16s per full cycle). Paused while off-screen, skipped
  // entirely under reduced motion. ----------------------------------------
  useEffect(() => {
    if (reduced) return;
    const root = rootRef.current;
    if (!root) return;
    let id = 0;
    const start = () => {
      if (id) return;
      id = window.setInterval(() => {
        setSymbolIndex((i) => (i + 1) % SYMBOLS.length);
      }, SYMBOL_INTERVAL_MS);
    };
    const stop = () => {
      if (!id) return;
      window.clearInterval(id);
      id = 0;
    };
    const io = new IntersectionObserver((entries) => {
      if (entries[0]?.isIntersecting) start();
      else stop();
    });
    io.observe(root);
    return () => {
      stop();
      io.disconnect();
    };
  }, [reduced]);

  const gap = cellSize * 0.14;
  const pattern = SYMBOLS[symbolIndex] ?? SYMBOLS[0]!;

  return (
    <div
      ref={rootRef}
      role="img"
      aria-label={label}
      className={`relative flex w-full items-center justify-center overflow-hidden ${className}`}
    >
      <div
        ref={gridRef}
        aria-hidden="true"
        style={{
          display: "grid",
          gridTemplateColumns: `repeat(${COLS}, ${cellSize}px)`,
          gridTemplateRows: `repeat(${ROWS}, ${cellSize}px)`,
          gap: `${gap}px`,
          visibility: cellSize > 0 ? "visible" : "hidden",
        }}
      >
        {pattern.map((open, i) => (
          <div key={i} style={{ position: "relative", width: cellSize, height: cellSize }}>
            <div
              style={{
                position: "absolute",
                inset: 0,
                border: "1px solid var(--foreground)",
                opacity: 0.25,
              }}
            />
            <div
              className="ns-shutter-panel"
              style={{
                position: "absolute",
                inset: 0,
                backgroundColor: "var(--foreground)",
                transformOrigin: "50% 50%",
                transform: `perspective(${PERSPECTIVE_PX}px) rotateY(${open ? 90 : 0}deg)`,
                transitionProperty: reduced ? "none" : "transform",
                transitionDuration: `${FLIP_MS}ms`,
                transitionTimingFunction: "ease-in-out",
                transitionDelay: `${i * STAGGER_MS}ms`,
              }}
            />
          </div>
        ))}
      </div>
      <style>{`
@media (prefers-reduced-motion: reduce){
  .ns-shutter-panel{transition:none!important;}
}
`}</style>
    </div>
  );
}
Build spec

A 2-column x 3-row grid of shutters filling a card, sized entirely from the container's smaller dimension (cellSize = minDim/4, measured via ResizeObserver on the root) so it holds its identity at card scale. Each shutter is two stacked absolutely-positioned divs inside a relative cell: a fixed 1px `border: 1px solid var(--foreground)` frame at `opacity: 0.25` (the shutter's edge, deliberately NOT `--border`, since at ~1.1:1 contrast in light theme that token would be invisible and this line is load-bearing for reading which cells are open) sitting behind an opaque `backgroundColor: var(--foreground)` panel whose only animated property is `transform: perspective(600px) rotateY(0deg | 90deg)` — 0deg is closed/face-on (the panel fully covers the frame, reading as a solid black square), 90deg is open/edge-on (the perspective projection alone collapses the panel to a near-invisible sliver, revealing the low-opacity frame and the background behind it; no opacity animation is involved in the open/closed transition itself). One of 8 fixed 6-bit boolean patterns (SYMBOLS, row-major reading order) is applied at a time via React state; on every symbol change each of the 6 shutters that needs to flip gets a `transitionDelay` of `index * 60ms` in reading order (left-to-right, top-to-bottom) with a shared 320ms ease-in-out `transitionDuration`, so the change reads as a wave of flips crossing the board rather than a simultaneous snap — this is the one thing to visually follow, at a cadence of roughly one full symbol change every 2000ms (DWELL_MS 1700 + 5 x STAGGER_MS 60), giving an 8-symbol, ~16s unbounded loop that simply repeats. Colours are plain `var(--foreground)` references directly in inline styles, not JS-read RGB values — pure DOM/CSS with no per-pixel colour math, so (matching the shipped flag-hoist-run sibling) the CSS cascade already repaints on a theme class change for free and there is nothing for a getComputedStyle+MutationObserver dance to do. The interval driving symbol advancement is started/stopped by an IntersectionObserver on the root so nothing runs off-screen, and is never started at all under `prefers-reduced-motion: reduce` — instead the board is pinned, synchronously in that effect (before any timer would otherwise fire), to symbol index 4: an exact checkerboard of 3 open / 3 closed shutters, the maximum structural contrast of any symbol in the set, deliberately distinct from the degenerate all-closed (index 0) and all-open (index 5) symbols that also exist elsewhere in the sequence and which would read as a blank board if chosen as the freeze frame; the CSS transition itself is also stripped via a `prefers-reduced-motion` media query as a second guard. The root is `role="img"` with an accessible label (default "Signal board"); the grid itself is `aria-hidden`. `autoplay: none` — the board advances on its own internal 2s timer with no pointer, press, or scroll input in the loop at all. Zero dependencies, DOM + CSS only, no canvas, every colour a token (--foreground only).

Props

PropTypeDefaultDescription
label?string"Signal board"accessible label for the ambient board
className?stringextra classes merged onto the rendered root element