Reveal row
List row with a hover preview.
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>
Copy into components/ph/ (or 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);
};
}
reveal-row.tsx
"use client";
import { useEffect, useRef } from "react";
import { cn } from "./cn";
import { Link } from "./link";
import { attachReveal } from "./reveal";
import "./reveal-row.css";
type RevealRowProps = {
title: string;
href?: string;
meta?: string;
image?: string;
description?: string;
className?: string;
};
export function RevealRow({ title, href, meta, image, description, className }: RevealRowProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => (ref.current ? attachReveal(ref.current) : undefined), []);
return (
<div
ref={ref}
className={cn(
"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 className="truncate leading-[1.35]">{href ? <Link href={href}>{title}</Link> : title}</span>
{meta && <span className="truncate text-[13px] leading-[1.35] sm:text-right">{meta}</span>}
<div className="ph-reveal-row__card">
<div className="ph-reveal-row__frame aspect-square w-[232px] border border-border">
{image ? <img className="size-full object-cover" src={image} alt="" loading="lazy" decoding="async" /> : <div className="dither-field size-full" />}
</div>
{description && <p className="ph-reveal-row__text text-[13px] leading-[1.4]">{description}</p>}
</div>
</div>
);
}
cn.ts
export const cn = (...parts: (string | false | null | undefined)[]) => parts.filter(Boolean).join(" ");