网点图像
New把任意位图在 canvas 上渲染成印刷网点:明暗决定网点大小,网格可旋转(45° 才是印刷味),圆点或方点、疏密可调。再给一张彩色版,灰网点便在悬停时交叉淡入彩色版,每个点取它所在位置的颜色。点与点之间透明,可直接叠在任何表面上。
Hover any plate: the grey screen crossfades to a colour one, each dot inked with the tone it sits on.
"use client";
import { useEffect, useState } from "react";
import { HalftoneImage } from "@/components/motion/halftone-image";
/**
* The lab ships no photographs, so the demo plates are drawn here: a lit sphere
* over a horizon. Smooth tonal ramps are exactly what a halftone screen is for,
* so a synthetic subject shows the dots off as well as a photo would — and it
* works offline, with no asset in the repo.
*
* `mono` is the resting plate; `color` is the one hover reveals.
*/
function drawPlate(mode: "mono" | "color"): string {
const size = 640;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return "";
const sky = ctx.createLinearGradient(0, 0, 0, size);
if (mode === "color") {
sky.addColorStop(0, "#1d2a6b");
sky.addColorStop(0.55, "#8d3f86");
sky.addColorStop(1, "#e2703a");
} else {
sky.addColorStop(0, "#0d0d0d");
sky.addColorStop(0.55, "#6b6b6b");
sky.addColorStop(1, "#d8d8d8");
}
ctx.fillStyle = sky;
ctx.fillRect(0, 0, size, size);
// Lit sphere: an off-centre radial makes a terminator, which screens into a
// visible tonal ramp rather than a flat disc.
const cx = size * 0.5;
const cy = size * 0.46;
const radius = size * 0.28;
const body = ctx.createRadialGradient(
cx - radius * 0.4,
cy - radius * 0.45,
radius * 0.05,
cx,
cy,
radius,
);
if (mode === "color") {
body.addColorStop(0, "#fff6de");
body.addColorStop(0.45, "#f2b25c");
body.addColorStop(1, "#3a1f4e");
} else {
body.addColorStop(0, "#ffffff");
body.addColorStop(0.45, "#a8a8a8");
body.addColorStop(1, "#141414");
}
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = body;
ctx.fill();
// Ground plane, darker than the sky so the horizon survives screening.
const ground = ctx.createLinearGradient(0, size * 0.72, 0, size);
if (mode === "color") {
ground.addColorStop(0, "#2b1530");
ground.addColorStop(1, "#0b0710");
} else {
ground.addColorStop(0, "#2a2a2a");
ground.addColorStop(1, "#070707");
}
ctx.fillStyle = ground;
ctx.fillRect(0, size * 0.72, size, size * 0.28);
return canvas.toDataURL("image/png");
}
const SAMPLES = [
{ cell: 4, label: "cell 4 · fine", angle: 45, shape: "circle" as const },
{ cell: 7, label: "cell 7 · 45° screen", angle: 45, shape: "circle" as const },
{ cell: 9, label: "cell 9 · square, 0°", angle: 0, shape: "square" as const },
];
export function HalftoneImagePreview() {
const [plates, setPlates] = useState<{ mono: string; color: string } | null>(null);
// Canvas is unavailable during SSR, so the plates are generated after mount.
useEffect(() => {
setPlates({ mono: drawPlate("mono"), color: drawPlate("color") });
}, []);
return (
<div className="flex w-full flex-col items-center gap-4 p-6">
<div className="grid w-full max-w-3xl gap-4 sm:grid-cols-3">
{SAMPLES.map((sample) => (
<figure key={sample.label} className="flex flex-col gap-2">
<div className="relative aspect-square overflow-hidden rounded-2xl bg-neutral-950">
{plates ? (
<HalftoneImage
src={plates.mono}
colorSrc={plates.color}
alt="A lit sphere above a horizon, rendered as a halftone screen"
cell={sample.cell}
angle={sample.angle}
shape={sample.shape}
className="h-full w-full text-white"
/>
) : null}
</div>
<figcaption className="text-center font-mono text-[0.65rem] text-muted-foreground">
{sample.label}
</figcaption>
</figure>
))}
</div>
<p className="text-center text-xs text-muted-foreground">
Hover any plate: the grey screen crossfades to a colour one, each dot inked
with the tone it sits on.
</p>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/motion/halftone-image
import { useEffect, useRef } from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type HalftoneDotShape = "circle" | "square";
export interface HalftoneImageProps {
/** Source bitmap. Any image the browser can decode; it is screened, not displayed. */
src: string;
/**
* Optional second plate revealed on hover. Same framing as `src` — usually the
* colour original against a desaturated `src`. Omit for a screen that never
* changes; on touch devices it is never rendered, since there is no hover.
*/
colorSrc?: string;
/** Describes the picture. The canvases are hidden from AT; the wrapper carries this. */
alt: string;
/** Grid pitch in CSS px — the screen's coarseness. 4–6 reads as print, 10+ as a poster. */
cell?: number;
/** Largest dot as a fraction of `cell`. Above ~0.7 the darkest dots touch and the grid closes up. */
dotScale?: number;
/** Screen rotation in degrees. Print screens sit at 45° so the grid stops reading as rows. */
angle?: number;
shape?: HalftoneDotShape;
/** Screen the inverse: dark dots for a light surface. */
invert?: boolean;
/**
* Dot colour. Defaults to the wrapper's computed `color`, so a `text-*` class
* drives it and it follows the theme.
*/
color?: string;
className?: string;
}
/** Rec. 709 luma. Matches how the eye weights the channels, so tone survives screening. */
function luminance(r: number, g: number, b: number): number {
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
/**
* `object-fit: cover` in numbers: the scale and offset that fill `w`×`h` with an
* `iw`×`ih` bitmap without distorting it.
*/
export function coverFit(
iw: number,
ih: number,
w: number,
h: number,
): { scale: number; dx: number; dy: number } {
const scale = Math.max(w / iw, h / ih);
return { scale, dx: (w - iw * scale) / 2, dy: (h - ih * scale) / 2 };
}
/**
* Dot radius for a tone. Area — not radius — is proportional to the tone, which
* is how a real halftone reproduces greys: a 25% dot must cover a quarter of its
* cell, so the radius goes as the square root.
*/
export function dotRadius(tone: number, cell: number, dotScale: number): number {
const clamped = tone < 0 ? 0 : tone > 1 ? 1 : tone;
return Math.sqrt(clamped) * (cell / 2) * dotScale;
}
/**
* Lattice points of a screen rotated `angle` degrees about the centre of a
* `w`×`h` box, covering it completely. Yields device-space coordinates.
*/
export function* screenLattice(
w: number,
h: number,
cell: number,
angle: number,
): Generator<{ x: number; y: number }> {
const rad = (angle * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const cx = w / 2;
const cy = h / 2;
// A rotated grid must cover the box's diagonal to leave no bare corner.
const reach = Math.hypot(w, h) / 2 + cell;
// Snapped to a whole cell so a dot always lands on the exact centre and the
// grid's phase does not depend on the box size — otherwise the whole screen
// shifts as the container resizes.
const start = -Math.ceil(reach / cell) * cell;
for (let v = start; v <= reach; v += cell) {
for (let u = start; u <= reach; u += cell) {
const x = cx + u * cos - v * sin;
const y = cy + u * sin + v * cos;
if (x < -cell || y < -cell || x > w + cell || y > h + cell) continue;
yield { x, y };
}
}
}
type Plate = { kind: "flat"; color: string } | { kind: "sampled" };
/**
* Screens one bitmap onto one canvas. `plate: "sampled"` colours every dot with
* the tone it sat on, which is what separates the colour plate from the grey one.
*
* Exported so you can screen onto a canvas you own — the React component is a
* thin wrapper over this.
*/
export function paintHalftone(
canvas: HTMLCanvasElement,
image: HTMLImageElement,
options: {
cell: number;
dotScale: number;
angle: number;
shape: HalftoneDotShape;
invert: boolean;
plate: Plate;
dpr: number;
cssWidth: number;
cssHeight: number;
},
): void {
const { cell, dotScale, angle, shape, invert, plate, dpr, cssWidth, cssHeight } =
options;
const w = Math.max(1, Math.round(cssWidth * dpr));
const h = Math.max(1, Math.round(cssHeight * dpr));
canvas.width = w;
canvas.height = h;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
// The source is rasterised once at output size, then sampled per dot.
const source = document.createElement("canvas");
source.width = w;
source.height = h;
const sourceCtx = source.getContext("2d", { willReadFrequently: true });
if (!sourceCtx) return;
const { scale, dx, dy } = coverFit(image.naturalWidth, image.naturalHeight, w, h);
sourceCtx.drawImage(
image,
dx,
dy,
image.naturalWidth * scale,
image.naturalHeight * scale,
);
let pixels: Uint8ClampedArray;
try {
pixels = sourceCtx.getImageData(0, 0, w, h).data;
} catch {
// A cross-origin bitmap taints the canvas and blocks reads. Showing the
// picture unscreened beats showing nothing.
ctx.drawImage(
image,
dx,
dy,
image.naturalWidth * scale,
image.naturalHeight * scale,
);
return;
}
const deviceCell = cell * dpr;
const maxRadius = deviceCell / 2;
if (plate.kind === "flat") ctx.fillStyle = plate.color;
for (const { x, y } of screenLattice(w, h, deviceCell, angle)) {
const px = Math.min(w - 1, Math.max(0, Math.round(x)));
const py = Math.min(h - 1, Math.max(0, Math.round(y)));
const i = (py * w + px) * 4;
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const alpha = pixels[i + 3] / 255;
if (alpha === 0) continue;
const lum = luminance(r, g, b);
const tone = (invert ? 1 - lum : lum) * alpha;
const radius = dotRadius(tone, deviceCell, dotScale);
if (radius < 0.05) continue;
if (plate.kind === "sampled") ctx.fillStyle = `rgb(${r} ${g} ${b})`;
if (shape === "square") {
// Match the circle's ink coverage so the two shapes read at the same weight.
const side = radius * Math.sqrt(Math.PI);
ctx.fillRect(x - side / 2, y - side / 2, side, side);
} else {
ctx.beginPath();
ctx.arc(x, y, Math.min(radius, maxRadius), 0, Math.PI * 2);
ctx.fill();
}
}
}
/**
* Renders a bitmap as a printed halftone screen: the image's tone drives dot
* size on a rotated grid, drawn to canvas. Give it a second plate via
* `colorSrc` and the grey screen crossfades to a colour one on hover.
*
* The canvas is transparent between dots, so it composes over whatever sits
* behind it — pair it with a `halftone-*` background atom or plain surface.
* For a generative dithered *wave* rather than a screened picture, see the
* `dither` variant of `webgl-background`.
*/
export function HalftoneImage({
src,
colorSrc,
alt,
cell = 6,
dotScale = 0.62,
angle = 45,
shape = "circle",
invert = false,
color,
className,
}: HalftoneImageProps) {
const containerRef = useRef<HTMLDivElement>(null);
const baseRef = useRef<HTMLCanvasElement>(null);
const colorRef = useRef<HTMLCanvasElement>(null);
const hoverCapable = useHoverCapable();
const showColorPlate = Boolean(colorSrc) && hoverCapable;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let disposed = false;
let frame = 0;
const loaded = new Map<string, HTMLImageElement>();
function paintAll() {
if (disposed) return;
const width = container?.clientWidth ?? 0;
const height = container?.clientHeight ?? 0;
if (width === 0 || height === 0) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const resolvedColor =
color ?? getComputedStyle(container as Element).color ?? "#ffffff";
const base = baseRef.current;
const baseImage = loaded.get(src);
if (base && baseImage) {
paintHalftone(base, baseImage, {
cell,
dotScale,
angle,
shape,
invert,
plate: { kind: "flat", color: resolvedColor },
dpr,
cssWidth: width,
cssHeight: height,
});
}
const plate = colorRef.current;
const plateImage = colorSrc ? loaded.get(colorSrc) : undefined;
if (plate && plateImage) {
paintHalftone(plate, plateImage, {
cell,
dotScale,
angle,
shape,
invert,
plate: { kind: "sampled" },
dpr,
cssWidth: width,
cssHeight: height,
});
}
}
function schedule() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(paintAll);
}
const sources = showColorPlate && colorSrc ? [src, colorSrc] : [src];
for (const source of sources) {
const image = new Image();
image.crossOrigin = "anonymous";
image.decoding = "async";
image.onload = () => {
loaded.set(source, image);
schedule();
};
image.src = source;
}
const observer = new ResizeObserver(schedule);
observer.observe(container);
return () => {
disposed = true;
cancelAnimationFrame(frame);
observer.disconnect();
};
}, [src, colorSrc, cell, dotScale, angle, shape, invert, color, showColorPlate]);
return (
<div
ref={containerRef}
role="img"
aria-label={alt}
className={cn("group relative overflow-hidden", className)}
>
<canvas ref={baseRef} aria-hidden className="block h-full w-full" />
{showColorPlate ? (
<canvas
ref={colorRef}
aria-hidden
className="absolute inset-0 block h-full w-full opacity-0 transition-opacity duration-500 ease-out group-hover:opacity-100"
/>
) : null}
</div>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx tailwind-mergeAdd util files
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// ui-lab-ten.vercel.app/components/motion/halftone-image
import { useEffect, useRef } from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type HalftoneDotShape = "circle" | "square";
export interface HalftoneImageProps {
/** Source bitmap. Any image the browser can decode; it is screened, not displayed. */
src: string;
/**
* Optional second plate revealed on hover. Same framing as `src` — usually the
* colour original against a desaturated `src`. Omit for a screen that never
* changes; on touch devices it is never rendered, since there is no hover.
*/
colorSrc?: string;
/** Describes the picture. The canvases are hidden from AT; the wrapper carries this. */
alt: string;
/** Grid pitch in CSS px — the screen's coarseness. 4–6 reads as print, 10+ as a poster. */
cell?: number;
/** Largest dot as a fraction of `cell`. Above ~0.7 the darkest dots touch and the grid closes up. */
dotScale?: number;
/** Screen rotation in degrees. Print screens sit at 45° so the grid stops reading as rows. */
angle?: number;
shape?: HalftoneDotShape;
/** Screen the inverse: dark dots for a light surface. */
invert?: boolean;
/**
* Dot colour. Defaults to the wrapper's computed `color`, so a `text-*` class
* drives it and it follows the theme.
*/
color?: string;
className?: string;
}
/** Rec. 709 luma. Matches how the eye weights the channels, so tone survives screening. */
function luminance(r: number, g: number, b: number): number {
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
/**
* `object-fit: cover` in numbers: the scale and offset that fill `w`×`h` with an
* `iw`×`ih` bitmap without distorting it.
*/
export function coverFit(
iw: number,
ih: number,
w: number,
h: number,
): { scale: number; dx: number; dy: number } {
const scale = Math.max(w / iw, h / ih);
return { scale, dx: (w - iw * scale) / 2, dy: (h - ih * scale) / 2 };
}
/**
* Dot radius for a tone. Area — not radius — is proportional to the tone, which
* is how a real halftone reproduces greys: a 25% dot must cover a quarter of its
* cell, so the radius goes as the square root.
*/
export function dotRadius(tone: number, cell: number, dotScale: number): number {
const clamped = tone < 0 ? 0 : tone > 1 ? 1 : tone;
return Math.sqrt(clamped) * (cell / 2) * dotScale;
}
/**
* Lattice points of a screen rotated `angle` degrees about the centre of a
* `w`×`h` box, covering it completely. Yields device-space coordinates.
*/
export function* screenLattice(
w: number,
h: number,
cell: number,
angle: number,
): Generator<{ x: number; y: number }> {
const rad = (angle * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const cx = w / 2;
const cy = h / 2;
// A rotated grid must cover the box's diagonal to leave no bare corner.
const reach = Math.hypot(w, h) / 2 + cell;
// Snapped to a whole cell so a dot always lands on the exact centre and the
// grid's phase does not depend on the box size — otherwise the whole screen
// shifts as the container resizes.
const start = -Math.ceil(reach / cell) * cell;
for (let v = start; v <= reach; v += cell) {
for (let u = start; u <= reach; u += cell) {
const x = cx + u * cos - v * sin;
const y = cy + u * sin + v * cos;
if (x < -cell || y < -cell || x > w + cell || y > h + cell) continue;
yield { x, y };
}
}
}
type Plate = { kind: "flat"; color: string } | { kind: "sampled" };
/**
* Screens one bitmap onto one canvas. `plate: "sampled"` colours every dot with
* the tone it sat on, which is what separates the colour plate from the grey one.
*
* Exported so you can screen onto a canvas you own — the React component is a
* thin wrapper over this.
*/
export function paintHalftone(
canvas: HTMLCanvasElement,
image: HTMLImageElement,
options: {
cell: number;
dotScale: number;
angle: number;
shape: HalftoneDotShape;
invert: boolean;
plate: Plate;
dpr: number;
cssWidth: number;
cssHeight: number;
},
): void {
const { cell, dotScale, angle, shape, invert, plate, dpr, cssWidth, cssHeight } =
options;
const w = Math.max(1, Math.round(cssWidth * dpr));
const h = Math.max(1, Math.round(cssHeight * dpr));
canvas.width = w;
canvas.height = h;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
// The source is rasterised once at output size, then sampled per dot.
const source = document.createElement("canvas");
source.width = w;
source.height = h;
const sourceCtx = source.getContext("2d", { willReadFrequently: true });
if (!sourceCtx) return;
const { scale, dx, dy } = coverFit(image.naturalWidth, image.naturalHeight, w, h);
sourceCtx.drawImage(
image,
dx,
dy,
image.naturalWidth * scale,
image.naturalHeight * scale,
);
let pixels: Uint8ClampedArray;
try {
pixels = sourceCtx.getImageData(0, 0, w, h).data;
} catch {
// A cross-origin bitmap taints the canvas and blocks reads. Showing the
// picture unscreened beats showing nothing.
ctx.drawImage(
image,
dx,
dy,
image.naturalWidth * scale,
image.naturalHeight * scale,
);
return;
}
const deviceCell = cell * dpr;
const maxRadius = deviceCell / 2;
if (plate.kind === "flat") ctx.fillStyle = plate.color;
for (const { x, y } of screenLattice(w, h, deviceCell, angle)) {
const px = Math.min(w - 1, Math.max(0, Math.round(x)));
const py = Math.min(h - 1, Math.max(0, Math.round(y)));
const i = (py * w + px) * 4;
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const alpha = pixels[i + 3] / 255;
if (alpha === 0) continue;
const lum = luminance(r, g, b);
const tone = (invert ? 1 - lum : lum) * alpha;
const radius = dotRadius(tone, deviceCell, dotScale);
if (radius < 0.05) continue;
if (plate.kind === "sampled") ctx.fillStyle = `rgb(${r} ${g} ${b})`;
if (shape === "square") {
// Match the circle's ink coverage so the two shapes read at the same weight.
const side = radius * Math.sqrt(Math.PI);
ctx.fillRect(x - side / 2, y - side / 2, side, side);
} else {
ctx.beginPath();
ctx.arc(x, y, Math.min(radius, maxRadius), 0, Math.PI * 2);
ctx.fill();
}
}
}
/**
* Renders a bitmap as a printed halftone screen: the image's tone drives dot
* size on a rotated grid, drawn to canvas. Give it a second plate via
* `colorSrc` and the grey screen crossfades to a colour one on hover.
*
* The canvas is transparent between dots, so it composes over whatever sits
* behind it — pair it with a `halftone-*` background atom or plain surface.
* For a generative dithered *wave* rather than a screened picture, see the
* `dither` variant of `webgl-background`.
*/
export function HalftoneImage({
src,
colorSrc,
alt,
cell = 6,
dotScale = 0.62,
angle = 45,
shape = "circle",
invert = false,
color,
className,
}: HalftoneImageProps) {
const containerRef = useRef<HTMLDivElement>(null);
const baseRef = useRef<HTMLCanvasElement>(null);
const colorRef = useRef<HTMLCanvasElement>(null);
const hoverCapable = useHoverCapable();
const showColorPlate = Boolean(colorSrc) && hoverCapable;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let disposed = false;
let frame = 0;
const loaded = new Map<string, HTMLImageElement>();
function paintAll() {
if (disposed) return;
const width = container?.clientWidth ?? 0;
const height = container?.clientHeight ?? 0;
if (width === 0 || height === 0) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const resolvedColor =
color ?? getComputedStyle(container as Element).color ?? "#ffffff";
const base = baseRef.current;
const baseImage = loaded.get(src);
if (base && baseImage) {
paintHalftone(base, baseImage, {
cell,
dotScale,
angle,
shape,
invert,
plate: { kind: "flat", color: resolvedColor },
dpr,
cssWidth: width,
cssHeight: height,
});
}
const plate = colorRef.current;
const plateImage = colorSrc ? loaded.get(colorSrc) : undefined;
if (plate && plateImage) {
paintHalftone(plate, plateImage, {
cell,
dotScale,
angle,
shape,
invert,
plate: { kind: "sampled" },
dpr,
cssWidth: width,
cssHeight: height,
});
}
}
function schedule() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(paintAll);
}
const sources = showColorPlate && colorSrc ? [src, colorSrc] : [src];
for (const source of sources) {
const image = new Image();
image.crossOrigin = "anonymous";
image.decoding = "async";
image.onload = () => {
loaded.set(source, image);
schedule();
};
image.src = source;
}
const observer = new ResizeObserver(schedule);
observer.observe(container);
return () => {
disposed = true;
cancelAnimationFrame(frame);
observer.disconnect();
};
}, [src, colorSrc, cell, dotScale, angle, shape, invert, color, showColorPlate]);
return (
<div
ref={containerRef}
role="img"
aria-label={alt}
className={cn("group relative overflow-hidden", className)}
>
<canvas ref={baseRef} aria-hidden className="block h-full w-full" />
{showColorPlate ? (
<canvas
ref={colorRef}
aria-hidden
className="absolute inset-0 block h-full w-full opacity-0 transition-opacity duration-500 ease-out group-hover:opacity-100"
/>
) : null}
</div>
);
}
API 参考
srcstringSource bitmap. Any image the browser can decode; it is screened, not displayed.
—colorSrc?stringOptional second plate revealed on hover. Same framing as `src` — usually the colour original against a desaturated `src`. Omit for a screen that never changes; on touch devices it is never rendered, since there is no hover.
—altstringDescribes the picture. The canvases are hidden from AT; the wrapper carries this.
—cell?numberGrid pitch in CSS px — the screen's coarseness. 4–6 reads as print, 10+ as a poster.
6dotScale?numberLargest dot as a fraction of `cell`. Above ~0.7 the darkest dots touch and the grid closes up.
0.62angle?numberScreen rotation in degrees. Print screens sit at 45° so the grid stops reading as rows.
45shape?"square" | "circle"circleinvert?booleanScreen the inverse: dark dots for a light surface.
falsecolor?stringDot colour. Defaults to the wrapper's computed `color`, so a `text-*` class drives it and it follows the theme.
—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.