Dither button
Pixels flip one by one on hover.
Code
Copy into src/components/ph/ .
dither-button.css
.ph-dither-btn,
.ph-dither-btn[data-tone="light"] {
--btn-bg: var(--ph-black);
--pixel: var(--ph-purple);
--pixel-hover: var(--ph-black);
--label: var(--ph-cream);
--label-hover: var(--ph-cream);
}
.dark .ph-dither-btn:not([data-tone="light"]),
[data-tone="dark"] .ph-dither-btn:not([data-tone="light"]),
.ph-dither-btn[data-tone="dark"] {
--btn-bg: var(--ph-cream);
--pixel: var(--ph-cream);
--pixel-hover: var(--ph-yellow);
--label: var(--ph-black);
--label-hover: var(--ph-black);
}
.ph-dither-btn {
position: relative;
display: inline-flex;
width: fit-content;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0.75rem 1.5rem;
background: var(--btn-bg);
border: 0;
cursor: pointer;
font-family: var(--ph-font-mono);
font-size: 1.25rem;
font-weight: 700;
line-height: 1.4;
letter-spacing: 0.025em;
text-transform: uppercase;
text-decoration: none;
}
.ph-dither-btn__pixels {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
align-content: center;
pointer-events: none;
}
.ph-dither-btn__pixel {
position: relative;
aspect-ratio: 1;
background: var(--pixel);
}
/* Duration 0: each pixel snaps, only the delay staggers. */
.ph-dither-btn__pixel::after {
content: "";
position: absolute;
inset: 0;
background: var(--pixel-hover);
opacity: 0;
transition: opacity 0s calc(var(--i) * 18ms);
}
.ph-dither-btn__label {
position: relative;
z-index: 1;
color: var(--label);
transition: color 200ms ease;
}
.ph-dither-btn:focus-visible .ph-dither-btn__pixel::after {
opacity: 1;
}
.ph-dither-btn:focus-visible .ph-dither-btn__label {
color: var(--label-hover);
}
@media (hover: hover) {
.ph-dither-btn:hover .ph-dither-btn__pixel::after {
opacity: 1;
}
.ph-dither-btn:hover .ph-dither-btn__label {
color: var(--label-hover);
}
}
@media (prefers-reduced-motion: reduce) {
.ph-dither-btn__pixel::after,
.ph-dither-btn__label {
transition: none;
}
}
dither.ts
// Seeded so server and client render the same order.
export function pixelOrder(count: number, seed = count): number[] {
let s = seed >>> 0 || 1;
const rand = () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const order = Array.from({ length: count }, (_, i) => i);
for (let i = count - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
return order;
}
DitherButton.astro
---
import type { HTMLAttributes } from "astro/types";
import { pixelOrder } from "./dither";
import "./dither-button.css";
type Props = Omit<HTMLAttributes<"a"> & HTMLAttributes<"button">, "type"> & {
href?: string;
type?: "button" | "submit" | "reset";
tone?: "light" | "dark";
cols?: number;
rows?: number;
};
const { href, type = "button", tone, cols = 10, rows = 5, class: className, style, ...rest } = Astro.props;
const Tag = href ? "a" : "button";
---
<Tag
href={href}
type={href ? undefined : type}
data-tone={tone}
class:list={["ph-dither-btn", className]}
style={[`--cols: ${cols}`, style].filter(Boolean).join("; ")}
{...rest}
>
<span class="ph-dither-btn__pixels" aria-hidden="true">
{pixelOrder(cols * rows).map((i) => <span class="ph-dither-btn__pixel" style={`--i: ${i}`} />)}
</span>
<span class="ph-dither-btn__label"><slot /></span>
</Tag>
Copy into components/ph/ (or src/components/ph/).
dither-button.css
.ph-dither-btn,
.ph-dither-btn[data-tone="light"] {
--btn-bg: var(--ph-black);
--pixel: var(--ph-purple);
--pixel-hover: var(--ph-black);
--label: var(--ph-cream);
--label-hover: var(--ph-cream);
}
.dark .ph-dither-btn:not([data-tone="light"]),
[data-tone="dark"] .ph-dither-btn:not([data-tone="light"]),
.ph-dither-btn[data-tone="dark"] {
--btn-bg: var(--ph-cream);
--pixel: var(--ph-cream);
--pixel-hover: var(--ph-yellow);
--label: var(--ph-black);
--label-hover: var(--ph-black);
}
.ph-dither-btn {
position: relative;
display: inline-flex;
width: fit-content;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0.75rem 1.5rem;
background: var(--btn-bg);
border: 0;
cursor: pointer;
font-family: var(--ph-font-mono);
font-size: 1.25rem;
font-weight: 700;
line-height: 1.4;
letter-spacing: 0.025em;
text-transform: uppercase;
text-decoration: none;
}
.ph-dither-btn__pixels {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
align-content: center;
pointer-events: none;
}
.ph-dither-btn__pixel {
position: relative;
aspect-ratio: 1;
background: var(--pixel);
}
/* Duration 0: each pixel snaps, only the delay staggers. */
.ph-dither-btn__pixel::after {
content: "";
position: absolute;
inset: 0;
background: var(--pixel-hover);
opacity: 0;
transition: opacity 0s calc(var(--i) * 18ms);
}
.ph-dither-btn__label {
position: relative;
z-index: 1;
color: var(--label);
transition: color 200ms ease;
}
.ph-dither-btn:focus-visible .ph-dither-btn__pixel::after {
opacity: 1;
}
.ph-dither-btn:focus-visible .ph-dither-btn__label {
color: var(--label-hover);
}
@media (hover: hover) {
.ph-dither-btn:hover .ph-dither-btn__pixel::after {
opacity: 1;
}
.ph-dither-btn:hover .ph-dither-btn__label {
color: var(--label-hover);
}
}
@media (prefers-reduced-motion: reduce) {
.ph-dither-btn__pixel::after,
.ph-dither-btn__label {
transition: none;
}
}
dither.ts
// Seeded so server and client render the same order.
export function pixelOrder(count: number, seed = count): number[] {
let s = seed >>> 0 || 1;
const rand = () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const order = Array.from({ length: count }, (_, i) => i);
for (let i = count - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
return order;
}
dither-button.tsx
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
import { cn } from "./cn";
import { pixelOrder } from "./dither";
import "./dither-button.css";
type DitherButtonProps = {
children: ReactNode;
href?: string;
tone?: "light" | "dark";
cols?: number;
rows?: number;
className?: string;
style?: CSSProperties;
} & Omit<AnchorHTMLAttributes<HTMLAnchorElement> & ButtonHTMLAttributes<HTMLButtonElement>, "children">;
export function DitherButton({
children,
href,
tone,
cols = 10,
rows = 5,
className,
style,
type = "button",
...rest
}: DitherButtonProps) {
const inner = (
<>
<span className="ph-dither-btn__pixels" aria-hidden="true">
{pixelOrder(cols * rows).map((i) => (
<span key={i} className="ph-dither-btn__pixel" style={{ "--i": i } as CSSProperties} />
))}
</span>
<span className="ph-dither-btn__label">{children}</span>
</>
);
const props = {
"data-tone": tone,
className: cn("ph-dither-btn", className),
style: { "--cols": cols, ...style } as CSSProperties,
};
return href ? (
<a href={href} {...props} {...(rest as AnchorHTMLAttributes<HTMLAnchorElement>)}>
{inner}
</a>
) : (
<button type={type} {...props} {...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}>
{inner}
</button>
);
}
cn.ts
export const cn = (...parts: (string | false | null | undefined)[]) => parts.filter(Boolean).join(" ");