Skip to main content

ns-ui / loud

Background Halftone Rosette

A full-bleed ambient background of two same-ink halftone dot screens drifting at independent, non-commensurate angles: a real duotone/double-hit moiré rosette, with a coverage gradient keeping the frame edges readable and the visual center where the interference peaks.

Use when a full-bleed backdrop where the texture itself is the point — genuine same-ink dot-overlap moiré drifting forever at rest, pure CSS with zero rAF cost; pick background-gradient-shader instead for a calm moving current of color behind other content, or background-ascii-plasma when the field should read as a procedural ASCII surface rather than a print-process pattern.

Install

npx shadcn add https://design.helpmarq.com/r/background-halftone-rosette.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/loud/background-halftone-rosette/component.tsx
"use client";

import { useEffect, useState } from "react";
import type { CSSProperties } from "react";

// ---------------------------------------------------------------------------
// BackgroundHalftoneRosette — a full-bleed ambient background: two halftone
// dot screens of the SAME ink, each drifting at its own continuous angle,
// the way duotone/double-hit offset printing lays one ink down twice at two
// screen angles. The rosette is genuine dot-overlap moiré between two
// same-ink coverage fields, not a color trick — both screens are painted in
// --foreground on --background.
//
// Two screens, one Δθ. Real halftone practice cites a trio/quad of absolute
// screen angles (15/45/75...) for MULTI-ink separations; with only two
// screens here the meaningful parameter is the angular SEPARATION between
// them, not an absolute angle. Resting separation is ~30° (a standard
// print two-color offset); at rest each screen sits at ±15° so the visual
// centerline stays put. Once running, screen A spins at +0.6deg/s and
// screen B at -0.9deg/s — independent, non-commensurate rates, so Δθ drifts
// forever and the beat pattern (wavelength ~ pitch / (2 sin(Δθ/2))) never
// settles into a repeating state.
//
// Same-ink moiré peaks near 50% combined coverage and nearly vanishes near
// the extremes, so a uniform-coverage field would wash the whole frame to
// unreadable mid-grey — a bad backdrop for type. A third layer expresses a
// coverage gradient instead: near-transparent at the visual center (full
// dot contrast, the rosette is the point) diluting toward a background-
// tinted scrim at the frame edges. The dilution ramp is deliberately steep
// (see the stops below) rather than a slow fade: at hero/card scale, real
// headline copy docked toward an edge still lands well inside where the
// ramp has only reached its midpoint, not fully diluted — a slower fade
// left the mid-coverage moiré zone (the ~50%-grey region the docblock above
// warns is the least readable) sitting under real text instead of just
// under the empty corners.
//
// Blend mode is chosen FROM THE THEME, never hardcoded. The invariant that
// must hold in both themes: overlap value moves monotonically AWAY from
// --background and TOWARD --foreground as coverage increases. `multiply`
// darkens overlaps toward black — correct when ink is dark-on-light (light
// theme), backward when ink is light-on-dark (multiplying two light layers
// against a dark ground pulls overlaps toward the dark ground instead of
// toward the ink). `screen` lightens overlaps toward white — the mirror
// case, correct in dark theme. Which one applies is decided at mount (and
// re-decided on every theme flip) by comparing --foreground's luminance
// against --background's, read via getComputedStyle(document.documentElement)
// and re-read on a MutationObserver watching documentElement's class.
//
// Substrate is pure CSS: two oversized (150%, negative-offset so a rotated
// square still fully covers the container at any angle) tiled radial-
// gradient dot layers, each under its own infinite CSS rotation keyframe,
// blended per the rule above, plus the coverage-gradient scrim layer on
// top. Zero rAF. prefers-reduced-motion (and the `paused` prop) freeze both
// screens at the ~30° resting separation instead of an arbitrary mid-drift
// crest — the print-safe, minimal-rosette state.
// ---------------------------------------------------------------------------

export interface BackgroundHalftoneRosetteProps {
  /** halftone screen pitch (dot-grid cell size), in px. @default 8 */
  dotPitch?: number;
  /** screen A's continuous rotation rate, degrees/second. @default 0.6 */
  rateA?: number;
  /** screen B's continuous rotation rate, degrees/second. @default -0.9 */
  rateB?: number;
  /** freeze both screens at their resting ~30deg separation. @default false */
  paused?: boolean;
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** inline styles merged onto the root element */
  style?: CSSProperties;
}

type RGB = [number, number, number];

function parseHex(raw: string): RGB | null {
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
  if (!m) return null;
  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) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

function luminance([r, g, b]: RGB): number {
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

// resolved at mount + on every theme flip: "multiply" when --foreground is
// darker than --background (light theme, dark ink on light paper), "screen"
// when --foreground is lighter (dark theme, light ink on a dark ground).
function resolveBlendMode(): "multiply" | "screen" {
  if (typeof document === "undefined") return "multiply";
  const cs = getComputedStyle(document.documentElement);
  const bg = parseHex(cs.getPropertyValue("--background")) ?? [1, 1, 1];
  const fg = parseHex(cs.getPropertyValue("--foreground")) ?? [0.09, 0.09, 0.09];
  return luminance(fg) < luminance(bg) ? "multiply" : "screen";
}

function useReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onChange = () => setReduced(mq.matches);
    onChange();
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

const REST_SEPARATION = 15; // each screen sits at +/-15deg -> 30deg apart at rest

export function BackgroundHalftoneRosette({
  dotPitch = 8,
  rateA = 0.6,
  rateB = -0.9,
  paused = false,
  className = "",
  style,
}: BackgroundHalftoneRosetteProps) {
  const [blendMode, setBlendMode] = useState<"multiply" | "screen">("multiply");
  const reducedMotion = useReducedMotion();
  const isStatic = paused || reducedMotion;

  useEffect(() => {
    setBlendMode(resolveBlendMode());
    const observer = new MutationObserver(() => setBlendMode(resolveBlendMode()));
    observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
    return () => observer.disconnect();
  }, []);

  const dotRadius = dotPitch * 0.3; // ~28% raw coverage per screen alone, feathered ~0.4px to
  // avoid rasterizer aliasing manufacturing false beat structure under continuous rotation
  const durA = 360 / Math.max(0.001, Math.abs(rateA));
  const durB = 360 / Math.max(0.001, Math.abs(rateB));
  const dirA: "normal" | "reverse" = rateA < 0 ? "reverse" : "normal";
  const dirB: "normal" | "reverse" = rateB < 0 ? "reverse" : "normal";
  // negative animation-delay seeks each layer straight to the +/-15deg resting
  // separation at t=0, instead of both starting at 0deg (Deltatheta=0, no rosette
  // until the drift has had time to open up). For a "reverse" direction, p=0
  // renders the keyframe's END (360deg), so the seek fraction is identical.
  const delayA = -(REST_SEPARATION / 360) * durA;
  const delayB = -(REST_SEPARATION / 360) * durB;

  // sized off the larger container dimension via container queries, so the
  // oversized square's half-diagonal covers the container's half-diagonal at
  // every rotation angle regardless of aspect ratio (150% of the *smaller*
  // axis alone can leave bare background strips sweeping in near 90/270deg).
  const screenBase: CSSProperties = {
    position: "absolute",
    top: "calc(50% - 75cqmax)",
    left: "calc(50% - 75cqmax)",
    width: "150cqmax",
    height: "150cqmax",
    backgroundImage: `radial-gradient(circle, var(--foreground) 0px, var(--foreground) ${dotRadius}px, transparent ${dotRadius + 0.4}px)`,
    backgroundSize: `${dotPitch}px ${dotPitch}px`,
    backgroundRepeat: "repeat",
  };

  const layerAStyle: CSSProperties = isStatic
    ? { ...screenBase, transform: `rotate(${REST_SEPARATION}deg)` }
    : {
        ...screenBase,
        animationName: "ns-halftone-rot",
        animationDuration: `${durA}s`,
        animationDelay: `${delayA}s`,
        animationDirection: dirA,
        animationTimingFunction: "linear",
        animationIterationCount: "infinite",
      };

  const layerBStyle: CSSProperties = isStatic
    ? { ...screenBase, transform: `rotate(${-REST_SEPARATION}deg)`, mixBlendMode: blendMode }
    : {
        ...screenBase,
        mixBlendMode: blendMode,
        animationName: "ns-halftone-rot",
        animationDuration: `${durB}s`,
        animationDelay: `${delayB}s`,
        animationDirection: dirB,
        animationTimingFunction: "linear",
        animationIterationCount: "infinite",
      };

  return (
    <div
      aria-hidden="true"
      className={`relative h-full w-full overflow-hidden ${className}`}
      style={{ backgroundColor: "var(--background)", isolation: "isolate", containerType: "size", ...style }}
    >
      <div style={layerAStyle} />
      <div style={layerBStyle} />
      {/* coverage-gradient scrim: ~transparent at the visual center (full
          dot contrast, rosette is the point) diluting toward a
          background-tinted edge (~8% effective coverage, a readable zone
          for headline/CTA content). "ellipse closest-side" (not the
          default farthest-corner circle) so all four edges — not just the
          corners — reach the dilute stop. */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          backgroundImage: `radial-gradient(ellipse closest-side at 50% 50%,
            transparent 0%,
            transparent 20%,
            color-mix(in srgb, var(--background) 55%, transparent) 45%,
            color-mix(in srgb, var(--background) 85%, transparent) 70%,
            color-mix(in srgb, var(--background) 95%, transparent) 100%)`,
        }}
      />
      <style>{`
@keyframes ns-halftone-rot { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
`}</style>
    </div>
  );
}

BackgroundHalftoneRosette.displayName = "BackgroundHalftoneRosette";
Build spec

Build <BackgroundHalftoneRosette dotPitch? rateA? rateB? paused? className?> as a full-bleed ambient background of two overlaid halftone dot screens — the same single ink at two independent, continuously drifting angles, the way duotone/double-hit offset printing lays one ink down twice at different screen angles. TWO PASSES, ONE INK: this is not a color trick — both screens render in --foreground on --background, and the moiré rosette is genuine dot-overlap interference between same-ink coverage fields, the print phenomenon printers historically fought and this component makes the point of. COVERAGE GRADIENT SOLVES THE MID-GREY PROBLEM: same-ink moiré is strongest near 50% combined coverage and nearly invisible near the extremes, so a uniform-coverage field would pull the whole frame toward an unreadable mid-grey; instead coverage ramps from ~8% at the frame edges (a low-coverage reading zone for headline content, expressed as a --background-tinted scrim) toward full dot contrast at the visual center, where the rosette is the point. TWO NON-COMMENSURATE RATES: screen A rotates continuously at +0.6deg/s (default rateA), screen B at -0.9deg/s (default rateB), so their separation drifts forever rather than settling into a repeating period — moiré beat wavelength scales as pitch/(2*sin(deltaTheta/2)), so even this slow a drift produces a large, clearly visible shift in the beat pattern within a couple of seconds, keeping the field genuinely alive at rest. THEME-DEPENDENT BLEND MODE, NOT HARDCODED: overlap value must move monotonically away from --background and toward --foreground as coverage increases, in BOTH themes — mix-blend-mode:multiply is used when --foreground is darker than --background (light theme), screen when --foreground is lighter (dark theme), decided by comparing token luminance via getComputedStyle(document.documentElement) at mount and re-read on a MutationObserver watching documentElement's class; a hardcoded multiply would silently invert the coverage cue in dark theme. CSS-driven: two oversized (150%, negative-offset so a rotated square always fully covers the container) tiled radial-gradient dot layers, each under an independent infinite CSS rotation keyframe, zero rAF cost. prefers-reduced-motion and the paused prop both freeze the rotation at the screens' ~30deg resting separation (each screen held at +/-15deg) — the calm, print-safe minimal-rosette state, not an arbitrary mid-drift crest. Props: dotPitch (halftone screen pitch in px, default 8), rateA/rateB (deg/s, defaults 0.6/-0.9), paused, className.

Props

PropTypeDefaultDescription
dotPitch?number8halftone screen pitch (dot-grid cell size), in px. @default 8
rateA?number0.6screen A's continuous rotation rate, degrees/second. @default 0.6
rateB?number-0.9screen B's continuous rotation rate, degrees/second. @default -0.9
paused?booleanfalsefreeze both screens at their resting ~30deg separation. @default false
className?stringextra classes merged onto the rendered root element
style?CSSPropertiesinline styles merged onto the root element