Skip to main content

Halftone Portrait

A photograph rebuilt as a dot matrix: the image is downsampled to a small luma grid on an offscreen canvas, then drawn as one SVG of circles whose radius and opacity follow each cell's brightness — a halftone print, live in the DOM. Ships imageToFrame/srcToFrame helpers and accepts precomputed frames, custom palettes, and inversion for dark surfaces.

Micro InteractionReactCanvas 2DTailwind CSS
CSSTailwind

Manual

Create a file and paste the following code into it.

halftone-portrait.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"use client";

import { type HTMLAttributes, useCallback, useEffect, useState } from "react";
import { cn } from "@/lib/cn";

/* ------------------------------------------------------------------ */
/*  HalftonePortrait                                                   */
/* ------------------------------------------------------------------ */

/** Rows of per-cell luma values in [0, 1]. */
export type Frame = number[][];

interface HalftonePortraitProps extends HTMLAttributes<HTMLDivElement> {
  src?: string;
  /** Precomputed frame instead of an image [Optional] */
  data?: Frame;
  rows?: number;
  cols?: number;
  /** Dot diameter in px [Optional, default: 6] */
  size?: number;
  gap?: number;
  /** Dot color at full luma / at zero [Optional] */
  palette?: { on: string; off: string };
  brightness?: number;
  /** Map dark pixels to lit dots instead of bright ones [Optional, default: false] */
  invert?: boolean;
  ariaLabel?: string;
}

/** Downsample an image to a rows x cols luma frame via an offscreen canvas. */
export function imageToFrame(
  image: HTMLImageElement | HTMLCanvasElement,
  rows: number,
  cols: number,
  invert = false,
): Frame {
  const canvas = document.createElement("canvas");
  canvas.width = cols;
  canvas.height = rows;
  const ctx = canvas.getContext("2d")!;
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = "high";
  ctx.drawImage(image, 0, 0, cols, rows);

  const { data } = ctx.getImageData(0, 0, cols, rows);
  const frame: Frame = [];
  for (let r = 0; r < rows; r++) {
    const row: number[] = [];
    for (let c = 0; c < cols; c++) {
      const i = (r * cols + c) * 4;
      const luma = (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255;
      const alpha = data[i + 3] / 255;
      row.push(Math.round((invert ? 1 - luma : luma) * alpha * 100) / 100);
    }
    frame.push(row);
  }
  return frame;
}

export function srcToFrame(
  src: string,
  rows: number,
  cols: number,
  invert?: boolean,
): Promise<Frame> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => resolve(imageToFrame(img, rows, cols, invert));
    img.onerror = () => reject(new Error("Failed to load image"));
    img.src = src;
  });
}

/**
 * A photo re-rendered as a dot matrix: the image is downsampled to a small
 * luma grid on an offscreen canvas, then drawn as one SVG of circles whose
 * radius follows each cell's brightness — a halftone print, live in the DOM.
 * @param {string} src - Image to rasterize [Optional if data given]
 * @param {number} rows - Grid rows [Optional, default: 48]
 * @param {{on, off}} palette - Dot colors at full/zero luma [Optional]
 */
export function HalftonePortrait({
  src,
  data,
  rows = 48,
  cols = 48,
  size = 6,
  gap = 2,
  palette = { on: "#f4f4f5", off: "#27272a" },
  brightness = 1,
  invert = false,
  ariaLabel,
  className,
  ...props
}: HalftonePortraitProps) {
  const [frame, setFrame] = useState<Frame | null>(data ?? null);

  const load = useCallback(async () => {
    if (!src) return;
    try {
      setFrame(await srcToFrame(src, rows, cols, invert));
    } catch {
      setFrame(null);
    }
  }, [src, rows, cols, invert]);

  useEffect(() => {
    if (data) {
      setFrame(data);
      return;
    }
    load();
  }, [data, load]);

  const pitch = size + gap;
  return (
    <div
      role="img"
      aria-label={ariaLabel ?? "halftone image"}
      className={cn("inline-block", className)}
      {...props}
    >
      {frame && (
        <svg
          width={cols * pitch}
          height={rows * pitch}
          viewBox={`0 0 ${cols * pitch} ${rows * pitch}`}
          aria-hidden
        >
          {frame.map((row, r) =>
            row.map((value, c) => {
              const lit = Math.min(1, value * brightness);
              if (lit < 0.04) return null;
              return (
                <circle
                  key={`${r}-${c}`}
                  cx={c * pitch + pitch / 2}
                  cy={r * pitch + pitch / 2}
                  r={(size / 2) * (0.35 + 0.65 * lit)}
                  fill={lit > 0.55 ? palette.on : palette.off}
                  opacity={0.35 + 0.65 * lit}
                />
              );
            }),
          )}
        </svg>
      )}
    </div>
  );
}

Update the import paths to match your project setup.

Similar components

Tilt Pointer

Edge Veil Blur

Spotlight Follow

Keycap Hint

Install via CLI

Resource details

PublishedJuly 17, 2026
CategoryMicro Interaction
ReactCanvas 2DTailwind CSS