knot-ui
componenthand-togglesource: ok · refreshed
Theme Toggle
A dithered 'hand of god' dark-mode toggle with a circular view-transition reveal.
npx shadcn add "https://knot-ui.trupal.me/r/hand-toggle.json"free
Visual style
dark-futuristic
Interaction
scroll-reveal
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Files5 · source cached 10/8/2026, 1:00:58 AM
components/library/hand-toggle/index.ts
export { HandThemeToggle, type HandThemeToggleProps } from "./hand-toggle";
export type { ThemeController } from "./theme";
components/library/hand-toggle/hand-toggle.tsx
"use client";
import type * as React from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { createPortal } from "react-dom";
import { renderHandBitmap, type BitmapSource } from "./dither";
import { ensureHandStyles } from "./styles";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import type { ThemeController } from "./theme";
export interface HandThemeToggleProps {
leftImage?: string;
rightImage?: string;
columns?: number;
cellSize?: number;
lightColor?: string;
darkColor?: string;
travelDistance?: number;
gap?: number;
className?: string;
triggerClassName?: string;
children?: React.ReactNode;
onToggle?: (isDark: boolean) => void;
isDark?: boolean;
}
const SLIDE_IN_SECONDS = 0.35;
const HOLD_SECONDS = 1.25;
const SLIDE_OUT_SECONDS = 0.4;
const TOTAL_SECONDS = SLIDE_IN_SECONDS + HOLD_SECONDS + SLIDE_OUT_SECONDS;
const KEYFRAME_TIMES = [
0,
SLIDE_IN_SECONDS / TOTAL_SECONDS,
(SLIDE_IN_SECONDS + HOLD_SECONDS) / TOTAL_SECONDS,
1,
] as const;
const EASE_ENTRY = "cubic-bezier(0.65, 0, 0.35, 1)";
const EASE_EXIT = "cubic-bezier(0.55, 0, 0.85, 0.35)";
const MIN_SCALE = 0.16;
function runHandTimeline(el: HTMLCanvasElement, fromX: number): Animation {
return el.animate(
[
{ transform: `translateX(${fromX}px)`, easing: EASE_ENTRY },
{ transform: "translateX(0px)", offset: KEYFRAME_TIMES[1], easing: "linear" },
{ transform: "translateX(0px)", offset: KEYFRAME_TIMES[2], easing: EASE_EXIT },
{ transform: `translateX(${fromX}px)` },
],
{ duration: TOTAL_SECONDS * 1000, fill: "both" },
);
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => setReduced(query.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return reduced;
}
function computeScale(columns = 100, cellSize = 10, gap = 10, innerWidth?: number): number {
if (typeof window === "undefined" && innerWidth == null) return 1;
const w = innerWidth ?? window.innerWidth;
const unscaledPairWidth = columns * cellSize * 2 + gap;
return Math.min(1, Math.max(MIN_SCALE, w / unscaledPairWidth));
}
function computePairWidth(columns: number, cellSize: number, gap: number, scale: number): number {
return (columns * cellSize * 2 + gap) * scale;
}
function computeTravel(
travelDistance: number | undefined,
pairWidth: number,
innerWidth?: number,
): number {
if (travelDistance != null) return travelDistance;
if (typeof window === "undefined" && innerWidth == null) return 300;
const w = innerWidth ?? window.innerWidth;
return w / 2 + pairWidth;
}
function createFallbackHand(mirror: boolean): BitmapSource {
const width = 200;
const height = 280;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
if (mirror) {
ctx.translate(width, 0);
ctx.scale(-1, 1);
}
ctx.fillStyle = "#000000";
ctx.strokeStyle = "#000000";
ctx.beginPath();
ctx.roundRect(58, 150, 88, 112, 32);
ctx.fill();
ctx.lineCap = "round";
const fingers = [
{ x: 74, tip: 88 },
{ x: 97, tip: 66 },
{ x: 120, tip: 76 },
{ x: 141, tip: 100 },
];
for (const finger of fingers) {
ctx.lineWidth = 21;
ctx.beginPath();
ctx.moveTo(finger.x, 168);
ctx.lineTo(finger.x, finger.tip);
ctx.stroke();
}
ctx.lineWidth = 24;
ctx.beginPath();
ctx.moveTo(72, 196);
ctx.lineTo(34, 164);
ctx.stroke();
return { source: canvas, width, height };
}
interface HandAsset {
variants: { dark: HTMLCanvasElement; light: HTMLCanvasElement };
}
function blitHand(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
asset: HandAsset,
isDark: boolean,
) {
const variant = isDark ? asset.variants.dark : asset.variants.light;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(variant, 0, 0);
ctx.restore();
}
function parkHand(left: HTMLElement, right: HTMLElement, travel: number) {
left.style.transform = `translateX(${-travel}px)`;
right.style.transform = `translateX(${travel}px)`;
}
let clientMounted = false;
const mountListeners = new Set<() => void>();
function subscribeToMount(callback: () => void) {
mountListeners.add(callback);
if (!clientMounted) {
clientMounted = true;
queueMicrotask(() => {
for (const listener of mountListeners) listener();
});
}
return () => {
mountListeners.delete(callback);
};
}
function getClientMountSnapshot() {
return clientMounted;
}
function getServerMountSnapshot() {
return false;
}
function useHasMounted() {
return useSyncExternalStore(subscribeToMount, getClientMountSnapshot, getServerMountSnapshot);
}
export function HandThemeToggle({
leftImage = "/left.png",
rightImage = "/right.png",
columns = 100,
cellSize = 10,
lightColor = "#111111",
darkColor = "#eaeaea",
travelDistance,
gap = 10,
className,
triggerClassName,
children,
onToggle,
isDark: isDarkProp,
}: HandThemeToggleProps) {
const hasMounted = useHasMounted();
const { resolvedTheme, setTheme } = useTheme();
const internalController = useMemo<ThemeController>(
() => ({
isDark: resolvedTheme === "dark",
setDark: (next: boolean) => setTheme(next ? "dark" : "light"),
}),
[resolvedTheme, setTheme],
);
const prefersReducedMotion = usePrefersReducedMotion();
const isDark = isDarkProp ?? internalController.isDark;
const setNextTheme = useCallback(
(nextIsDark: boolean) => {
if (isDarkProp == null) internalController.setDark(nextIsDark);
onToggle?.(nextIsDark);
},
[isDarkProp, internalController, onToggle],
);
const leftRef = useRef<HTMLCanvasElement>(null);
const rightRef = useRef<HTMLCanvasElement>(null);
const leftCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const rightCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const leftSrcRef = useRef<BitmapSource | null>(null);
const rightSrcRef = useRef<BitmapSource | null>(null);
const leftAssetRef = useRef<HandAsset | null>(null);
const rightAssetRef = useRef<HandAsset | null>(null);
const [phase, setPhase] = useState<"idle" | "running">("idle");
const phaseRef = useRef<"idle" | "running">("idle");
const needsSyncRef = useRef(false);
const activeAnimationsRef = useRef<Animation[]>([]);
const rebuildGenerationRef = useRef(0);
const builtSignatureRef = useRef<{ left: string | null; right: string | null }>({
left: null,
right: null,
});
const swapTimerRef = useRef<number | null>(null);
const travelRef = useRef(300);
const buttonIsDark = hasMounted && isDark;
const isDarkRef = useRef(isDark);
useEffect(() => {
isDarkRef.current = isDark;
}, [isDark]);
const [innerWidth, setInnerWidth] = useState(() =>
typeof window !== "undefined" ? window.innerWidth : 0,
);
const [scale, setScale] = useState(() =>
computeScale(columns, cellSize, gap, typeof window !== "undefined" ? window.innerWidth : undefined),
);
const scaleRef = useRef(scale);
const innerWidthRef = useRef(innerWidth);
useEffect(() => {
scaleRef.current = scale;
}, [scale]);
useEffect(() => {
innerWidthRef.current = innerWidth;
}, [innerWidth]);
const stopActiveAnimations = useCallback(() => {
for (const a of activeAnimationsRef.current) a.cancel();
activeAnimationsRef.current = [];
}, []);
const invalidateRebuilds = useCallback(() => {
rebuildGenerationRef.current++;
builtSignatureRef.current = { left: null, right: null };
}, []);
const rebuild = useCallback(async () => {
const generation = ++rebuildGenerationRef.current;
const s = scaleRef.current;
const sides = [
{ key: "left" as const, src: leftSrcRef.current, canvas: leftRef.current, ctxOut: leftCtxRef, assetOut: leftAssetRef },
{ key: "right" as const, src: rightSrcRef.current, canvas: rightRef.current, ctxOut: rightCtxRef, assetOut: rightAssetRef },
];
for (const side of sides) {
if (!side.src || !side.canvas) continue;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.round(columns * cellSize * s);
const h = Math.round((side.src.height / side.src.width) * w);
const dw = Math.round(w * dpr);
const dh = Math.round(h * dpr);
const signature = `${dw}x${dh}|${darkColor}|${lightColor}`;
if (builtSignatureRef.current[side.key] === signature) continue;
if (side.canvas.width !== dw) side.canvas.width = dw;
if (side.canvas.height !== dh) side.canvas.height = dh;
side.canvas.style.width = `${w}px`;
side.canvas.style.height = `${h}px`;
const ctx = side.canvas.getContext("2d")!;
side.ctxOut.current = ctx;
const dark = renderHandBitmap(side.src, darkColor, true, dw, dh);
if (generation !== rebuildGenerationRef.current) return;
const light = renderHandBitmap(side.src, lightColor, false, dw, dh);
if (generation !== rebuildGenerationRef.current) return;
side.assetOut.current = { variants: { dark, light } };
blitHand(side.canvas, ctx, side.assetOut.current, isDarkRef.current);
builtSignatureRef.current[side.key] = signature;
await new Promise<void>((resolve) => {
if ("requestIdleCallback" in window) {
requestIdleCallback(() => resolve(), { timeout: 200 });
} else {
requestAnimationFrame(() => resolve());
}
});
if (generation !== rebuildGenerationRef.current) return;
}
}, [columns, cellSize, darkColor, lightColor]);
const repark = useCallback(() => {
const left = leftRef.current;
const right = rightRef.current;
if (!left || !right) return;
const pw = computePairWidth(columns, cellSize, gap, scaleRef.current);
const travel = computeTravel(travelDistance, pw, innerWidthRef.current);
travelRef.current = travel;
parkHand(left, right, travel);
}, [columns, cellSize, gap, travelDistance]);
useEffect(() => {
if (!hasMounted) return;
let cancelled = false;
const loadImage = (src: string, mirror: boolean, onReady: (src: BitmapSource) => void) => {
if (!src) {
onReady(createFallbackHand(mirror));
return;
}
const img = new Image();
img.crossOrigin = "anonymous";
const handleLoad = () => {
if (cancelled) return;
onReady({ source: img, width: img.naturalWidth, height: img.naturalHeight });
};
const handleError = () => {
if (cancelled) return;
onReady(createFallbackHand(mirror));
};
img.onload = handleLoad;
img.onerror = handleError;
img.src = src;
if (img.complete && img.naturalWidth) handleLoad();
if (img.complete && !img.naturalWidth) handleError();
};
loadImage(leftImage, false, (src) => {
leftSrcRef.current = src;
void rebuild();
});
loadImage(rightImage, true, (src) => {
rightSrcRef.current = src;
void rebuild();
});
return () => {
cancelled = true;
};
}, [hasMounted, leftImage, rightImage, rebuild]);
useEffect(() => {
if (!hasMounted) return;
ensureHandStyles();
document.documentElement.style.setProperty("--hvt-duration", `${HOLD_SECONDS}s`);
document.documentElement.style.setProperty("--hvt-easing", "var(--hvt-ease-expo-out)");
return () => {
invalidateRebuilds();
stopActiveAnimations();
if (swapTimerRef.current != null) {
window.clearTimeout(swapTimerRef.current);
swapTimerRef.current = null;
}
};
}, [hasMounted, stopActiveAnimations, invalidateRebuilds]);
useLayoutEffect(() => {
if (!hasMounted) return;
repark();
}, [hasMounted, repark]);
useEffect(() => {
if (!hasMounted) return;
let frame: number | null = null;
const onResize = () => {
if (frame != null) return;
frame = requestAnimationFrame(() => {
setInnerWidth(window.innerWidth);
setScale(computeScale(columns, cellSize, gap, window.innerWidth));
frame = null;
});
};
onResize();
window.addEventListener("resize", onResize);
window.visualViewport?.addEventListener("resize", onResize);
return () => {
if (frame != null) cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
window.visualViewport?.removeEventListener("resize", onResize);
};
}, [hasMounted, columns, cellSize, gap]);
useEffect(() => {
if (!hasMounted) return;
if (phaseRef.current === "running") {
needsSyncRef.current = true;
return;
}
void rebuild();
repark();
}, [hasMounted, scale, innerWidth, rebuild, repark]);
useEffect(() => {
if (!hasMounted) return;
if (phaseRef.current === "running") {
needsSyncRef.current = true;
return;
}
const left = leftRef.current;
const right = rightRef.current;
if (left && leftCtxRef.current && leftAssetRef.current) {
blitHand(left, leftCtxRef.current, leftAssetRef.current, isDark);
}
if (right && rightCtxRef.current && rightAssetRef.current) {
blitHand(right, rightCtxRef.current, rightAssetRef.current, isDark);
}
}, [hasMounted, isDark]);
const handleToggle = useCallback(async () => {
if (phaseRef.current !== "idle") return;
const left = leftRef.current;
const right = rightRef.current;
const leftAsset = leftAssetRef.current;
const rightAsset = rightAssetRef.current;
const leftCtx = leftCtxRef.current;
const rightCtx = rightCtxRef.current;
if (!left || !right || !leftAsset || !rightAsset || !leftCtx || !rightCtx) return;
const nextIsDark = !isDark;
const applySwap = () => {
setNextTheme(nextIsDark);
blitHand(left, leftCtx, leftAsset, nextIsDark);
blitHand(right, rightCtx, rightAsset, nextIsDark);
};
if (prefersReducedMotion) {
if (document.startViewTransition) {
document.startViewTransition(applySwap);
} else {
applySwap();
}
return;
}
phaseRef.current = "running";
setPhase("running");
for (const a of activeAnimationsRef.current) a.cancel();
activeAnimationsRef.current = [];
const travel = travelRef.current;
swapTimerRef.current = window.setTimeout(() => {
swapTimerRef.current = null;
if (document.startViewTransition) {
const transition = document.startViewTransition(applySwap);
transition.finished.catch(() => {});
} else {
applySwap();
}
}, SLIDE_IN_SECONDS * 1000);
try {
const animations = [
runHandTimeline(left, -travel),
runHandTimeline(right, travel),
];
activeAnimationsRef.current = animations;
await Promise.all(animations.map((a) => a.finished.catch(() => {})));
} finally {
for (const a of activeAnimationsRef.current) a.cancel();
activeAnimationsRef.current = [];
if (swapTimerRef.current != null) {
window.clearTimeout(swapTimerRef.current);
swapTimerRef.current = null;
}
if (needsSyncRef.current) {
needsSyncRef.current = false;
void rebuild();
}
repark();
phaseRef.current = "idle";
setPhase("idle");
}
}, [isDark, prefersReducedMotion, setNextTheme, rebuild, repark]);
const overlay = hasMounted
? createPortal(
<div
aria-hidden
data-running={phase === "running" ? "true" : undefined}
className={"hvt-overlay" + (className ? ` ${className}` : "")}
style={{ gap: gap * scale }}
>
<canvas ref={leftRef} className="hvt-canvas" />
<canvas ref={rightRef} className="hvt-canvas" />
</div>,
document.body,
)
: null;
return (
<>
<button
type="button"
role="switch"
aria-checked={buttonIsDark}
aria-busy={phase === "running"}
aria-label="Toggle dark mode"
disabled={!hasMounted}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
void handleToggle();
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
void handleToggle();
}}
className={"hvt-trigger" + (triggerClassName ? ` ${triggerClassName}` : "")}
>
{children ?? (buttonIsDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />)}
</button>
{overlay}
</>
);
}
components/library/hand-toggle/dither.ts
export interface BitmapSource {
source: CanvasImageSource;
width: number;
height: number;
}
const BAYER_8X8 = [
0, 32, 8, 40, 2, 34, 10, 42,
48, 16, 56, 24, 50, 18, 58, 26,
12, 44, 4, 36, 14, 46, 6, 38,
60, 28, 52, 20, 62, 30, 54, 22,
3, 35, 11, 43, 1, 33, 9, 41,
51, 19, 59, 27, 49, 17, 57, 25,
15, 47, 7, 39, 13, 45, 5, 37,
63, 31, 55, 23, 61, 29, 53, 21,
];
export function renderHandBitmap(
src: BitmapSource,
colorHex: string,
isDark: boolean,
outWidth: number,
outHeight: number,
): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = outWidth;
canvas.height = outHeight;
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
ctx.drawImage(src.source, 0, 0, outWidth, outHeight);
const imageData = ctx.getImageData(0, 0, outWidth, outHeight);
const pixels = imageData.data;
const count = outWidth * outHeight;
const luminance = new Float32Array(count);
const opaque = new Uint8Array(count);
for (let p = 0, i = 0; p < count; p++, i += 4) {
if (pixels[i + 3] < 128) continue;
opaque[p] = 1;
luminance[p] = 0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2];
}
if (isDark) {
ditherOrdered(luminance, opaque, outWidth, outHeight);
} else {
ditherAtkinson(luminance, opaque, outWidth, outHeight);
}
const r = parseInt(colorHex.slice(1, 3), 16);
const g = parseInt(colorHex.slice(3, 5), 16);
const b = parseInt(colorHex.slice(5, 7), 16);
for (let p = 0, i = 0; p < count; p++, i += 4) {
if (!opaque[p]) {
pixels[i + 3] = 0;
continue;
}
if (luminance[p] > 127) {
pixels[i + 3] = 0;
} else {
pixels[i] = r;
pixels[i + 1] = g;
pixels[i + 2] = b;
pixels[i + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
return canvas;
}
function ditherOrdered(
lum: Float32Array,
opaque: Uint8Array,
w: number,
h: number,
): void {
for (let y = 0; y < h; y++) {
const matrixRow = (y & 7) * 8;
for (let x = 0; x < w; x++) {
const p = y * w + x;
if (!opaque[p]) continue;
const threshold = ((BAYER_8X8[matrixRow + (x & 7)] + 0.5) / 64) * 255;
lum[p] = lum[p] > threshold ? 255 : 0;
}
}
}
function ditherAtkinson(
lum: Float32Array,
opaque: Uint8Array,
w: number,
h: number,
): void {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const p = y * w + x;
if (!opaque[p]) continue;
const old = lum[p];
const next = old > 127 ? 255 : 0;
lum[p] = next;
const error = (old - next) / 8;
spread(lum, opaque, x + 1, y, w, h, error);
spread(lum, opaque, x + 2, y, w, h, error);
spread(lum, opaque, x - 1, y + 1, w, h, error);
spread(lum, opaque, x, y + 1, w, h, error);
spread(lum, opaque, x + 1, y + 1, w, h, error);
spread(lum, opaque, x, y + 2, w, h, error);
}
}
}
function spread(
lum: Float32Array,
opaque: Uint8Array,
x: number,
y: number,
w: number,
h: number,
error: number,
): void {
if (x < 0 || x >= w || y >= h) return;
const p = y * w + x;
if (!opaque[p]) return;
lum[p] += error;
}
components/library/hand-toggle/styles.ts
const STYLE_ID = "hand-toggle-styles";
const CSS = `
:root {
--hvt-ease-expo-out: linear(
0 0%, 0.1684 2.66%, 0.3165 5.49%,
0.446 8.52%, 0.5581 11.78%,
0.6535 15.29%, 0.7341 19.11%,
0.8011 23.3%, 0.8557 27.93%,
0.8962 32.68%, 0.9283 38.01%,
0.9529 44.08%, 0.9711 51.14%,
0.9833 59.06%, 0.9915 68.74%, 1 100%
);
}
@keyframes hvt-circle-reveal {
from { clip-path: circle(0% at 50% 50%); }
to { clip-path: circle(100% at 50% 50%); }
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(root) {
animation: hvt-circle-reveal var(--hvt-duration, 1.25s) var(--hvt-easing, var(--hvt-ease-expo-out));
animation-fill-mode: both;
}
::view-transition-old(root),
.dark::view-transition-old(root) {
z-index: -1;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
transition: none !important;
}
.hvt-trigger {
transition: none !important;
}
}
.hvt-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
border: none;
background: transparent;
color: inherit;
font-size: 1rem;
line-height: 1;
cursor: pointer;
outline: none;
transition: background-color 0.15s ease;
}
.hvt-trigger:hover {
background-color: color-mix(in srgb, currentColor 8%, transparent);
}
.hvt-trigger:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.hvt-trigger[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.hvt-trigger[aria-busy="true"] {
pointer-events: none;
}
.hvt-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
pointer-events: none;
visibility: hidden;
}
.hvt-overlay[data-running="true"] {
visibility: visible;
}
.hvt-canvas {
will-change: transform;
image-rendering: pixelated;
}
`;
export function ensureHandStyles(): void {
if (typeof document === "undefined") return;
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
components/library/hand-toggle/theme.ts
export interface ThemeController {
isDark: boolean;
setDark(next: boolean): void;
}