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.

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.

Source
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;
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?stringextra classes merged onto the rendered root element
style?CSSPropertiesinline styles merged onto the root element