Skip to main content

ns-ui

Card Dot Gain Screen

A card backed by a contact-screen halftone that reproduces real dot gain: a nonlinear per-dot growth curve where the deepest tones grow past the point of staying separate circles and physically bridge into plugged solid ink.

Usage

registry/core/card-dot-gain-screen/demo.tsx
"use client";

import { CardDotGainScreen } from "./component";

export default function CardDotGainScreenDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">ns-ui / card-dot-gain-screen</p>

      <CardDotGainScreen />

      <p className="max-w-md text-center text-xs text-ns-muted">
        The screen runs the full plate — highlight to shadow — behind the copy; watch the bottom-right
        corner over a few seconds as the dots swell, bridge and plug solid, then ease back.
      </p>
    </div>
  );
}

Component

registry/core/card-dot-gain-screen/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// CardDotGainScreen — a card whose backing texture is a contact-screen
// halftone reproduced with real ink behaviour, not a decorative dot grid.
// Offset printing does not scale a halftone dot cleanly: the plate's
// nominal tone value runs through a NONLINEAR dot-gain curve (ink spreads
// past the plate's own edge, growing a dot's printed area beyond its
// theoretical size — heaviest in the midtones, per Murray-Davies), and in
// the deepest tones the grown dots physically touch their neighbours and
// PLUG: the paper between adjacent dots disappears and a patch of "texture"
// becomes a patch of solid ink. That plugging, not the dot shape, is the
// mechanic this component reproduces — chart-bar-halftone and
// chart-donut-halftone elsewhere in this registry both use a clean,
// LINEARLY size-scaled dot per data value on a chart surface; this is a
// card, and every dot's radius here comes off a nonlinear gain curve that
// is allowed to push a dot's printed area past the point where it stays a
// separate circle, so dense regions visibly bridge and merge instead of
// just growing bigger.  background-halftone-rosette (loud) is the other
// printing-family neighbour: two same-pitch dot screens at drifting angles
// producing moiré interference — a two-screen registration effect, no gain
// curve, no merging, and it runs full-bleed with no card content to
// protect. This component is a single screen with per-dot growth.
//
// Ink math: a dot's PRINTED area comes from its plate tone t (0=highlight,
// 1=shadow) via printedArea(t) = t + gain*midtoneBump(t) + gain*shadowPlug(t):
//   midtoneBump  = 4t(1-t)              — Murray-Davies parabola, heaviest ~50%
//   shadowPlug   = smoothstep(0.68,1,t)^2 — near-zero until t~0.68, then
//                  accelerates hard toward 1 — models the extra ink-spread
//                  that specifically hits shadow regions in real contact
//                  printing, on top of the ordinary midtone bump
// printedArea is allowed to exceed 1 (clamped to 1.6) so a dot's rendered
// radius r = pitch/2 * sqrt(printedArea) can grow past pitch/2 — the radius
// at which a dot on a square screen touches its orthogonal neighbours.
// Past that point two filled, opaque circles drawn overlapping is what
// "plugging" IS: the canvas fill covers the gap between them the same way
// spreading ink would. Nothing special-cases the merge; it falls out of
// letting the radius exceed tangency.
//
// Idle drift: `gain` above is not a constant. Two incommensurate sine terms
// (periods 7.3s / 11.7s, never in phase together twice inside any short
// window) walk it between roughly 0.22 and 0.38, standing in for plate
// humidity and ink-feed variance drifting a real press's dot gain over a
// run. A slow per-dot noise field (coordinates offset by time*0.05) adds
// a small amount of jitter on top so individual dots swell and shrink out
// of lockstep — the plug/bridge boundary between screen and solid visibly
// creeps rather than holding a fixed shape, with no input required.
//
// Legibility: a card carries text. A screen printed at uniform mid-to-high
// tone would plug solid under the copy and erase it. Rather than punching
// a hole (an opacity mask reads as "the effect turned off"), the plate
// tone itself is scaled down under the content block — the same "coverage
// gradient" solution background-halftone-rosette uses for its full-bleed
// scrim, applied locally: t is multiplied by a falloff that is 1 outside
// the text's padded bounding box and ~0.32 inside it with a soft-edged
// transition, so the copy sits over a sparse, still-visibly-printed
// highlight patch while the card's margins run the full gain curve up
// into plugged shadow — the mechanic stays fully on display, just not
// directly behind the letters.
// ---------------------------------------------------------------------------

export interface CardDotGainScreenProps {
  /** card heading */
  title?: string;
  /** card body copy */
  description?: string;
  /** trailing link label; omit to render the card with no link */
  linkLabel?: string;
  /** link href, used only when linkLabel is set */
  href?: string;
  /** 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 parseColor(raw: string): RGB | null {
  const s = raw.trim();
  if (s.startsWith("#")) {
    let hex = s.slice(1);
    if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("");
    if (hex.length < 6) return null;
    const r = parseInt(hex.slice(0, 2), 16);
    const g = parseInt(hex.slice(2, 4), 16);
    const b = parseInt(hex.slice(4, 6), 16);
    return Number.isNaN(r + g + b) ? null : [r, g, b];
  }
  const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}

// deterministic per-cell hash, coordinates offset by time elsewhere to drift
function hash01(x: number, y: number): number {
  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;
  return n - Math.floor(n);
}

function smoothstep(edge0: number, edge1: number, x: number): number {
  const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
  return t * t * (3 - 2 * t);
}

// gain(t) walks between the two SEC constants below via two incommensurate
// sine terms; freeze time (reduced motion) lands here for maximum plugging.
const GAIN_BASE = 0.3;
const GAIN_AMP1 = 0.05;
const GAIN_AMP2 = 0.035;
const PERIOD1 = 7.3; // seconds
const PERIOD2 = 11.7; // seconds
// t at which sin(2*pi*t/PERIOD1) first peaks — chosen as the reduced-motion
// freeze frame because both waves sit near their maxima there together
// (PERIOD1/4 = 1.825s; PERIOD2 is not a multiple of it, so this is the
// nearest simultaneous near-peak, not an exact one), giving the richest,
// most legibly plugged shadow band to freeze on rather than an arbitrary t=0.
const STATIC_TIME = PERIOD1 / 4;

export function CardDotGainScreen({
  title = "Contact screen, plate 4",
  description = "Dot gain is not a defect to correct out — past a threshold the screen stops separating tones and starts plugging solid.",
  linkLabel = "Read the proof sheet",
  href = "#",
  className = "",
  style,
}: CardDotGainScreenProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    const content = contentRef.current;
    if (!root || !canvas || !content) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

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

    // -- token-derived ink: read at mount, re-derived on theme class change --
    let fg: RGB = [237, 237, 237];
    const derive = () => {
      const cs = getComputedStyle(document.documentElement);
      fg = parseColor(cs.getPropertyValue("--foreground")) ?? fg;
    };
    derive();

    let w = 0;
    let h = 0;
    let dpr = 1;
    let pitch = 6;
    let raf = 0;
    let visible = true;
    let clearBox = { x: 0, y: 0, w: 0, h: 0 };

    const measureClearBox = () => {
      const rootRect = root.getBoundingClientRect();
      const cRect = content.getBoundingClientRect();
      // padded a bit beyond the literal text box so the falloff has room to
      // land at ~1 before the box edge instead of clipping hard at it
      const pad = pitch * 2.2;
      clearBox = {
        x: cRect.left - rootRect.left - pad,
        y: cRect.top - rootRect.top - pad,
        w: cRect.width + pad * 2,
        h: cRect.height + pad * 2,
      };
    };

    // multiplies plate tone down to ~0.32 inside the padded content box,
    // 1 outside it, with a soft-edged transition band one pitch wide
    const clearFactor = (x: number, y: number): number => {
      const dx = x < clearBox.x ? clearBox.x - x : x > clearBox.x + clearBox.w ? x - (clearBox.x + clearBox.w) : 0;
      const dy = y < clearBox.y ? clearBox.y - y : y > clearBox.y + clearBox.h ? y - (clearBox.y + clearBox.h) : 0;
      const dist = Math.sqrt(dx * dx + dy * dy);
      const band = pitch * 3;
      const inside = 1 - smoothstep(0, band, dist);
      return 1 - inside * 0.68;
    };

    const draw = (now: number) => {
      if (w <= 0 || h <= 0) return;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);

      const time = reduced ? STATIC_TIME : now / 1000;
      const gain =
        GAIN_BASE +
        GAIN_AMP1 * Math.sin((2 * Math.PI * time) / PERIOD1) +
        GAIN_AMP2 * Math.sin((2 * Math.PI * time) / PERIOD2 + 1.1);
      const noiseT = time * 0.05;

      const cols = Math.ceil(w / pitch) + 1;
      const rows = Math.ceil(h / pitch) + 1;
      ctx.fillStyle = `rgb(${fg[0]},${fg[1]},${fg[2]})`;
      ctx.beginPath();
      for (let j = 0; j < rows; j++) {
        const cy = j * pitch + pitch / 2;
        for (let i = 0; i < cols; i++) {
          const cx = i * pitch + pitch / 2;
          // print-swatch base tone: diagonal ramp, highlight top-left to
          // shadow bottom-right, so one card shows the whole gain curve
          const baseT = Math.min(1, Math.max(0, (cx / w + cy / h) / 2));
          const t = baseT * clearFactor(cx, cy);

          const midtoneBump = 4 * t * (1 - t);
          const shadowPlug = Math.pow(smoothstep(0.68, 1, t), 2);
          let area = t + gain * midtoneBump * 0.55 + gain * shadowPlug * 0.9;
          // per-dot jitter: slow-drifting noise field, +/-6% of area
          const jitter = (hash01(i * 0.37 + noiseT, j * 0.53 - noiseT) - 0.5) * 0.12;
          area = Math.min(1.6, Math.max(0, area + jitter));
          if (area <= 0.002) continue;

          const r = (pitch / 2) * Math.sqrt(area);
          ctx.moveTo(cx + r, cy);
          ctx.arc(cx, cy, r, 0, Math.PI * 2);
        }
      }
      ctx.fill();
    };

    const loop = (now: number) => {
      draw(now);
      if (!reduced && visible) raf = requestAnimationFrame(loop);
      else raf = 0;
    };
    const wake = () => {
      if (raf === 0 && !reduced && visible) raf = requestAnimationFrame(loop);
    };

    const resize = () => {
      const rect = root.getBoundingClientRect();
      w = rect.width;
      h = rect.height;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      // screen ruling proportional to the card's own smaller dimension, so
      // it never coarsens into a polka-dot pattern on a small card and
      // never over-fines on a large one
      pitch = Math.min(9, Math.max(3.5, Math.min(w, h) / 32));
      measureClearBox();
      draw(reduced ? 0 : performance.now());
    };

    resize();
    if (!reduced) wake();
    else draw(0);

    const ro = new ResizeObserver(resize);
    ro.observe(root);

    const mo = new MutationObserver(() => {
      derive();
      draw(reduced ? 0 : performance.now());
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    const onReducedChange = () => {
      reduced = mq.matches;
      if (reduced) {
        cancelAnimationFrame(raf);
        raf = 0;
        draw(0);
      } else {
        wake();
      }
    };
    mq.addEventListener("change", onReducedChange);

    const onVisibility = () => {
      visible = document.visibilityState === "visible";
      if (visible) {
        wake();
      } else {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    };
    document.addEventListener("visibilitychange", onVisibility);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      mo.disconnect();
      mq.removeEventListener("change", onReducedChange);
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  return (
    <div
      ref={rootRef}
      className={`ns-cdgs relative w-full max-w-sm overflow-hidden rounded-[14px] border border-border bg-background ${className}`}
      style={style}
    >
      <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full" />
      <div ref={contentRef} className="relative flex flex-col gap-3 p-6">
        <h3 className="text-balance font-sans text-lg font-medium text-foreground">{title}</h3>
        <p className="text-pretty font-mono text-xs leading-relaxed text-ns-muted">{description}</p>
        {linkLabel ? (
          <a
            href={href}
            className="ns-cdgs-link mt-1 inline-flex w-fit items-center gap-1 rounded-sm font-mono text-xs font-medium text-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
          >
            {linkLabel}
            <span aria-hidden="true">&rarr;</span>
          </a>
        ) : null}
      </div>
    </div>
  );
}

CardDotGainScreen.displayName = "CardDotGainScreen";

export default CardDotGainScreen;

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

Use when a card whose backing texture should read as a genuine printed contact screen — dots that grow along a nonlinear gain curve and visibly bridge into plugged solid ink in the deepest tones, drifting idly like plate humidity and ink variance on a real press. Pick chart-bar-halftone or chart-donut-halftone instead when the dot pattern needs to encode a data value with clean, linearly size-scaled dots on a chart; pick background-halftone-rosette for a full-bleed two-screen moiré backdrop with no card content to protect.

Install

npx shadcn add https://design.helpmarq.com/r/card-dot-gain-screen.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 a card whose entire backing surface is a canvas-rendered contact-screen halftone, sourced from real offset-printing dot gain, not a decorative dot grid. Root is a `rounded-[14px] border border-border bg-background` card holding an absolutely positioned, `aria-hidden` canvas filling the card behind a normal content stack (heading, mono body copy, optional underlined link with a `focus-visible` ring in `--ns-accent`). Per dot, compute a plate tone `t` in [0,1] from a diagonal print-swatch gradient across the card (highlight top-left to shadow bottom-right, `(cx/w + cy/h)/2`) so a single card demonstrates the whole gain curve at once. Convert `t` to a PRINTED area via `printedArea = t + gain*midtoneBump(t) + gain*shadowPlug(t)`, where `midtoneBump = 4t(1-t)` is the classic Murray-Davies parabola (heaviest gain around 50% tone) and `shadowPlug = smoothstep(0.68,1,t)^2` is near-zero until deep shadow and then accelerates hard toward 1 — an explicit second term for the ink-spread that specifically hits shadow regions, on top of the ordinary midtone bump. Let `printedArea` exceed 1 (clamp at 1.6) and derive a dot's rendered radius as `r = pitch/2 * sqrt(printedArea)`. `pitch/2` is exactly the radius at which a dot on a square screen touches its orthogonal neighbours; letting the radius grow past that and simply filling the (now overlapping) circles is what makes adjacent dots visually PLUG — the merge is not special-cased, it falls out of overlapping opaque fills, same as ink spreading past a dot's plate edge in reality. This is the mechanic that distinguishes the component from chart-bar-halftone/chart-donut-halftone's clean, linearly SIZE-SCALED dots (no gain curve, no merge) and from background-halftone-rosette's two-screen moiré interference (registration between two screens, not per-dot growth). `gain` is not constant: walk it via two incommensurate sine terms (periods 7.3s and 11.7s, base ~0.30, amplitudes 0.05/0.035) so the plugged-shadow boundary visibly creeps over a period of several seconds with no input — standing in for plate humidity and ink-feed variance drifting a real press's dot gain over a run. Layer a small per-dot jitter on top from a deterministic hash noise field whose coordinates are offset by slow-moving time, so individual dots swell and shrink slightly out of lockstep rather than the whole screen breathing uniformly. Screen pitch is derived from the card's own smaller dimension (`clamp(minDim/32, 3.5, 9)` px) via `ResizeObserver`, never a fixed px value, so it stays a fine screen rather than coarsening into visible polka dots on a small card. Legibility: rather than punching an opacity hole over the text (which reads as the effect switching off), scale the plate tone `t` down to ~32% of its value inside the content block's padded bounding box, with a soft one-pitch-wide falloff back to 100% outside it — the same 'coverage gradient' idea background-halftone-rosette uses for its full-bleed scrim, applied locally, so the copy sits over a sparse but still-visibly-printed highlight patch while the card's corners run the full curve up into plugged shadow. Ink color is `--foreground` read via `getComputedStyle(document.documentElement)` at mount and re-derived on a `MutationObserver` watching `documentElement`'s class — never a literal — so 'more ink' correctly means more `--foreground` coverage and the polarity is automatically right in both themes. `--ns-accent` never touches the screen itself, only the link's focus ring. DPR-capped (max 2) backing store sized off the card's own `getBoundingClientRect`. The animation loop is a plain `requestAnimationFrame`, paused via `visibilitychange` when the tab is hidden, and stopped entirely under `prefers-reduced-motion: reduce`, which freezes the clock at `PERIOD1/4` (≈1.825s) — the point nearest a simultaneous peak of both drift waves — so the reduced-motion frame lands on the richest, most legibly plugged shadow band rather than an arbitrary t=0. No dependencies.

Props

PropTypeDefaultDescription
title?string"Contact screen, plate 4"card heading
description?string"Dot gain is not a defect to correct out — past a threshold the screen stops separating tones and starts plugging solid."card body copy
linkLabel?string"Read the proof sheet"trailing link label; omit to render the card with no link
href?string"#"link href, used only when linkLabel is set
className?string—extra classes merged onto the rendered root element
style?CSSProperties—inline styles merged onto the root element