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.

Usage

registry/loud/background-halftone-rosette/demo.tsx
"use client";

import { BackgroundHalftoneRosette } from "./component";

export default function BackgroundHalftoneRosetteDemo() {
  return (
    <div className="relative h-screen w-full overflow-hidden bg-background">
      <div className="absolute inset-0">
        <BackgroundHalftoneRosette />
      </div>
      {/* content sits toward the low-coverage edge zone the scrim protects,
          not over the dense rosette at the visual center */}
      <div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-start gap-3 p-10 text-left">
        <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
          ns-ui / background-halftone-rosette
        </p>
        <h1 className="max-w-xl text-4xl font-semibold tracking-tight text-foreground">
          Two screens, one ink
        </h1>
        <p className="max-w-sm text-sm text-ns-muted">
          Same-ink halftone screens drifting at independent angles — the moiré
          rosette is real dot-overlap interference, not a color trick.
        </p>
      </div>
    </div>
  );
}

Component

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";

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

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.

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?string—extra classes merged onto the rendered root element
style?CSSProperties—inline styles merged onto the root element