ph/components

Reveal row

List row with a hover preview.

pixel plotter ada & grace

a pen plotter that draws in Bayer-dithered strokes.

glider clock alan

lightning time, on a wall.

acrylic badge cutter linus

weekly badges, cut by members.

Code

Copy into src/components/ph/ .

reveal-row.css
/* translate and scale stay separate so per-frame writes don't fight hover. */
.ph-reveal-row__card {
  position: fixed;
  top: 0;
  left: 0;
  translate: var(--card-x, 0) var(--card-y, 0);
  z-index: 20;
  display: flex;
  width: calc(232px + 32px);
  flex-direction: column;
  gap: 10px;
  padding: 16px;
  background: var(--background);
  border: 1px solid var(--border);
  box-shadow: 0 8px 28px rgb(0 0 0 / 0.1);
  opacity: 0;
  scale: 0.97;
  transform-origin: top left;
  pointer-events: none;

  --dur: 200ms;
  --beat: 55ms;

  transition:
    opacity 120ms var(--ph-ease),
    scale 120ms var(--ph-ease);
}

.ph-reveal-row__frame {
  flex: none;
}

.ph-reveal-row__text {
  opacity: 0;
  translate: 0 4px;
  transition:
    opacity 120ms var(--ph-ease),
    translate 120ms var(--ph-ease);
}

[data-ph-warm] .ph-reveal-row__card {
  --dur: 110ms;
  --beat: 25ms;
}

.ph-reveal-row:is(:hover, :has(:focus-visible)) .ph-reveal-row__card {
  opacity: 1;
  scale: 1;
  transition-duration: var(--dur);
}

.ph-reveal-row:is(:hover, :has(:focus-visible)) .ph-reveal-row__text {
  opacity: 1;
  translate: 0 0;
  transition-duration: var(--dur);
  transition-delay: var(--beat);
}

@media (hover: none) {
  .ph-reveal-row__card {
    position: static;
    grid-column: 1 / -1;
    width: auto;
    padding: 6px 0 0;
    border: 0;
    background: none;
    box-shadow: none;
    opacity: 1;
    translate: none;
    scale: none;
    pointer-events: auto;
  }

  .ph-reveal-row__frame {
    display: none;
  }

  .ph-reveal-row__text {
    opacity: 1;
    translate: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .ph-reveal-row__card,
  .ph-reveal-row:is(:hover, :has(:focus-visible)) .ph-reveal-row__card {
    scale: 1;
    transition: opacity 120ms linear;
  }

  .ph-reveal-row__text,
  .ph-reveal-row:is(:hover, :has(:focus-visible)) .ph-reveal-row__text {
    translate: none;
    transition: opacity 120ms linear;
    transition-delay: 0ms;
  }
}
reveal.ts
const PAD = 16;
const OFFSET = 36;

let card: HTMLElement | null = null;
let w = 0;
let h = 0;
let tx = 0;
let ty = 0;
let cx = 0;
let cy = 0;
let raf = 0;
let cool: ReturnType<typeof setTimeout> | undefined;

// Trailing rather than 1:1, so it settles when the pointer stops.
const ease = () => (matchMedia("(prefers-reduced-motion: reduce)").matches ? 1 : 0.22);

function target(x: number, y: number) {
  let left = x + OFFSET;
  if (left + w + PAD > innerWidth) left = x - OFFSET - w;
  tx = Math.max(PAD, Math.min(left, innerWidth - w - PAD));

  const maxTop = innerHeight - h - PAD;
  ty = maxTop <= PAD ? PAD : PAD + (y / innerHeight) * (maxTop - PAD);
}

function write() {
  card!.style.setProperty("--card-x", `${Math.round(cx)}px`);
  card!.style.setProperty("--card-y", `${Math.round(cy)}px`);
}

function step() {
  if (!card) {
    raf = 0;
    return;
  }
  const k = ease();
  cx += (tx - cx) * k;
  cy += (ty - cy) * k;
  write();
  raf = requestAnimationFrame(step);
}

function open(row: HTMLElement, x: number, y: number) {
  card = row.querySelector<HTMLElement>(".ph-reveal-row__card");
  if (!card || !matchMedia("(hover: hover)").matches) return;

  w = card.offsetWidth;
  h = card.offsetHeight;
  target(x, y);
  cx = tx;
  cy = ty;
  write();

  clearTimeout(cool);
  document.documentElement.setAttribute("data-ph-warm", "");
  if (!raf) raf = requestAnimationFrame(step);
}

function close() {
  card = null;
  cool = setTimeout(() => document.documentElement.removeAttribute("data-ph-warm"), 400);
}

export function attachReveal(row: HTMLElement) {
  const enter = (e: PointerEvent) => open(row, e.clientX, e.clientY);
  const move = (e: PointerEvent) => {
    if (card) target(e.clientX, e.clientY);
  };
  const focus = () => {
    const r = row.getBoundingClientRect();
    open(row, r.left + 24, r.bottom);
  };

  row.addEventListener("pointerenter", enter);
  row.addEventListener("pointermove", move);
  row.addEventListener("pointerleave", close);
  row.addEventListener("focusin", focus);
  row.addEventListener("focusout", close);

  return () => {
    row.removeEventListener("pointerenter", enter);
    row.removeEventListener("pointermove", move);
    row.removeEventListener("pointerleave", close);
    row.removeEventListener("focusin", focus);
    row.removeEventListener("focusout", close);
  };
}
RevealRow.astro
---
import Link from "./Link.astro";
import "./reveal-row.css";

type Props = {
  title: string;
  href?: string;
  meta?: string;
  image?: string;
  description?: string;
  class?: string;
};

const { title, href, meta, image, description, class: className } = Astro.props;
---

<div
  data-ph-reveal
  class:list={[
    "ph-reveal-row relative -mx-3 grid items-baseline gap-x-4 border-b border-border px-3 py-3 transition-colors duration-150 ease-quint hover:bg-foreground/[0.03] sm:grid-cols-[minmax(0,1fr)_auto]",
    className,
  ]}
>
  <span class="truncate leading-[1.35]">{href ? <Link href={href}>{title}</Link> : title}</span>
  {meta && <span class="truncate text-[13px] leading-[1.35] sm:text-right">{meta}</span>}

  <div class="ph-reveal-row__card">
    <div class="ph-reveal-row__frame aspect-square w-[232px] border border-border">
      {image ? <img class="size-full object-cover" src={image} alt="" loading="lazy" decoding="async" /> : <div class="dither-field size-full" />}
    </div>
    {description && <p class="ph-reveal-row__text text-[13px] leading-[1.4]">{description}</p>}
  </div>
</div>

<script>
  import { attachReveal } from "./reveal";

  for (const row of document.querySelectorAll<HTMLElement>("[data-ph-reveal]")) attachReveal(row);
</script>

Also needs