ph/components

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>