Overlays
Peek
A quick look at a row or link while Space or the pointer is held, gone on release. No click, no navigation.
- Built on
Popover API@starting-stylearia-describedby- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.3 kB
- Dependencies
- React
Code
import { Peek } from "@/components/equip/peek";
const row = {
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
height: 34,
padding: "0 8px",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-s)",
background: "var(--eq-raised)",
color: "var(--eq-ink)",
font: "var(--eq-weight) var(--eq-text-s) / 1 var(--eq-font)",
textAlign: "start",
cursor: "pointer",
} as const;
const mono = { fontFamily: "var(--eq-font-mono)", fontVariationSettings: "var(--eq-font-mono-settings)" };
const quiet = { marginInlineStart: "auto", color: "var(--eq-ink-2)" };
const summary = { margin: 0, display: "grid", gap: 4 };
export default function Demo() {
return (
<div style={{ display: "grid", gap: 6, width: 208 }}>
<Peek
label="Deploy a3f9c1"
preview={
<div style={summary}>
<strong>Fix invoice rounding</strong>
<span style={{ color: "var(--eq-ink-2)" }}>main by Priya, built in 48s, live in eu-west-1</span>
</div>
}
>
<button type="button" style={row}>
<span style={mono}>a3f9c1</span> main <span style={quiet}>Ready</span>
</button>
</Peek>
<Peek
label="Deploy 7be20d"
preview={
<div style={summary}>
<strong>Add audit log export</strong>
<span style={{ color: "var(--eq-ink-2)" }}>feat/audit by Tomas, failed at type check after 31s</span>
</div>
}
>
<button type="button" style={row}>
<span style={mono}>7be20d</span> feat/audit <span style={quiet}>Failed</span>
</button>
</Peek>
<span style={{ color: "var(--eq-ink-3)", font: "var(--eq-text-xs) / 1.3 var(--eq-font)" }}>
Hold Space on a row, or press and hold
</span>
</div>
);
}Examples
Link in running text
Only a hold opens the preview, so a click still follows the link. Inside a paragraph, keep the preview to phrasing content such as spans.
Rounding moved to the ledger service in BIL-412Hold Space to previewBIL-412 Invoice totals off by one centClosed by Priya Natarajan on 3 November. 4 comments, 2 linked deploys., so totals now match the PDF.
Code
import { Peek } from "@/components/equip/peek";
export default function Example() {
return (
<p style={{ margin: 0, maxWidth: 360, font: "var(--eq-text-m) / 1.5 var(--eq-font)", color: "var(--eq-ink)" }}>
Rounding moved to the ledger service in{" "}
<Peek
label="Issue BIL-412"
placement="bottom"
preview={
<span style={{ display: "grid", gap: 4 }}>
<strong>BIL-412 Invoice totals off by one cent</strong>
<span style={{ color: "var(--eq-ink-2)" }}>Closed by Priya Natarajan on 3 November. 4 comments, 2 linked deploys.</span>
</span>
}
>
<a href="#bil-412" style={{ color: "inherit", textUnderlineOffset: 3 }}>
BIL-412
</a>
</Peek>
, so totals now match the PDF.
</p>
);
}Quick look in a file list
The preview sits beside each row and flips to the other side when the viewport runs out of room.
- Hold Space to previewq3-board-report.pdf12 pages, edited by Ana 2 hours agoRevenue up 18% on Q2, churn flat at 2.1%
- Hold Space to previewpricing-2026.xlsx3 sheets, edited by Kofi yesterdayTeam plan moves to 14 per seat in January
- Hold Space to previewbrand-guidelines.fig42 frames, edited by Mei last weekNew wordmark, two greys removed from the palette
Code
import { Peek } from "@/components/equip/peek";
const files = [
{ name: "q3-board-report.pdf", size: "2.4 MB", lines: ["12 pages, edited by Ana 2 hours ago", "Revenue up 18% on Q2, churn flat at 2.1%"] },
{ name: "pricing-2026.xlsx", size: "310 KB", lines: ["3 sheets, edited by Kofi yesterday", "Team plan moves to 14 per seat in January"] },
{ name: "brand-guidelines.fig", size: "18 MB", lines: ["42 frames, edited by Mei last week", "New wordmark, two greys removed from the palette"] },
];
const row = {
display: "flex",
width: "100%",
height: 34,
alignItems: "center",
padding: "0 8px",
border: 0,
borderRadius: "var(--eq-radius-s)",
background: "transparent",
color: "var(--eq-ink)",
font: "var(--eq-text-s) / 1 var(--eq-font)",
cursor: "pointer",
} as const;
export default function Example() {
return (
<ul style={{ listStyle: "none", margin: 0, padding: 0, width: "100%", maxWidth: 280 }}>
{files.map((file) => (
<li key={file.name}>
<Peek
label={`${file.name} preview`}
preview={
<div style={{ display: "grid", gap: 4 }}>
<strong>{file.name}</strong>
{file.lines.map((line) => (
<span key={line} style={{ color: "var(--eq-ink-2)" }}>{line}</span>
))}
</div>
}
>
<button type="button" style={row}>
{file.name}
<span style={{ marginInlineStart: "auto", color: "var(--eq-ink-3)" }}>{file.size}</span>
</button>
</Peek>
</li>
))}
</ul>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the preview panel for assistive tech, such as "Deploy a3f9c1". |
preview | ReactNode | none | What the panel shows while held. |
children | ReactElement | none | Exactly one focusable element, usually a button or link. It gets aria-describedby merged in. |
placement | "end" | "bottom" | "end" | Beside the trigger, flipping to the start side, or below it, flipping above. |
...rest | ComponentProps<"span"> | none | Native attributes for the wrapping span, which uses display: contents. |
Anatomy
| root | .eq-peek | A display: contents wrapper, so it never changes layout. |
|---|---|---|
| hint | .eq-peek-hint | Visually hidden "Hold Space to preview", read as the trigger's description. |
| panel | .eq-peek-panel | A manual popover in the top layer, placed from the trigger's rect. |
Keyboard
| Space | Held for 250ms on the focused trigger, shows the preview until released. |
|---|---|
| Space | Tapped, activates a native button, summary or check control as usual. |
| Escape | Hides the preview while Space is still held. |
Notes
- Pointer users press and hold for 300ms; releasing hides the preview and the click that would follow is swallowed, so nothing navigates.
- On touch a long press shows the preview until the finger lifts; moving the finger first cancels it, so scrolling still works.
- Holding Space while hovering works only when nothing else has focus, so it never takes Space away from a focused control; while the pointer rests on a trigger, Space does not scroll the page.
- The panel has pointer-events: none and holds no state; put anything people need to act on in the page itself.
- While shown the panel joins the trigger's aria-describedby, and a hidden hint tells screen reader users the gesture exists.
- On a link, a tap of Space no longer scrolls the page while it is focused, because Space is reserved for the hold.
- Needs the Popover API. Where showPopover is missing the hold does nothing and the trigger keeps its normal behaviour.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/peek.tsx266 lines
"use client";
import {
useEffect,
useId,
useRef,
useState,
type ComponentProps,
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type ReactElement,
type ReactNode,
} from "react";
import "./peek.css";
const KEY_HOLD = 250;
const POINTER_HOLD = 300;
const GAP = 8;
const SLOP = 6;
const ACTIVATES_ON_SPACE =
'button, summary, input:is([type="button"], [type="submit"], [type="reset"], [type="checkbox"], [type="radio"])';
export type PeekProps = Omit<ComponentProps<"span">, "children"> & {
/** Names the preview panel for assistive tech, e.g. "Deploy a3f9c1 summary". */
label: string;
/** What the panel shows while held. */
preview: ReactNode;
/** Exactly one focusable element: a link, a button or a row. */
children: ReactElement<{ "aria-describedby"?: string }>;
/** Beside the trigger (flips to the start side) or below it (flips above). */
placement?: "end" | "bottom";
};
type Key = Pick<
globalThis.KeyboardEvent,
"key" | "repeat" | "altKey" | "ctrlKey" | "metaKey" | "preventDefault" | "stopPropagation"
>;
type Hold = { source: "key" | "pointer"; shown: boolean; timer: number; x: number; y: number };
export function Peek({
label,
preview,
children,
placement = "end",
className,
onKeyDown,
onKeyUp,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onPointerEnter,
onPointerLeave,
onClickCapture,
onContextMenu,
onBlur,
...rest
}: PeekProps) {
const id = useId();
const hintId = `${id}-hint`;
const panelId = `${id}-panel`;
const rootRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLSpanElement>(null);
const hold = useRef<Hold | null>(null);
const swallowClick = useRef(false);
const [shown, setShown] = useState(false);
const [hovered, setHovered] = useState(false);
function trigger() {
const first = rootRef.current?.firstElementChild;
return first instanceof HTMLElement ? first : null;
}
function show() {
const panel = panelRef.current;
const el = trigger();
if (!panel || !el || typeof panel.showPopover !== "function") return;
if (!panel.matches(":popover-open")) panel.showPopover();
const r = el.getBoundingClientRect();
const w = panel.offsetWidth;
const h = panel.offsetHeight;
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
let x: number;
let y: number;
let side: string;
if (placement === "end") {
side = r.right + GAP + w <= vw - GAP || r.left - GAP - w < GAP ? "end" : "start";
x = side === "end" ? r.right + GAP : r.left - GAP - w;
y = r.top;
} else {
side = r.bottom + GAP + h <= vh - GAP || r.top - GAP - h < GAP ? "bottom" : "top";
x = r.left;
y = side === "bottom" ? r.bottom + GAP : r.top - GAP - h;
}
panel.style.left = `${Math.max(GAP, Math.min(x, vw - w - GAP))}px`;
panel.style.top = `${Math.max(GAP, Math.min(y, vh - h - GAP))}px`;
panel.dataset.side = side;
if (hold.current) hold.current.shown = true;
setShown(true);
}
function hide() {
const panel = panelRef.current;
if (panel?.matches(":popover-open")) panel.hidePopover();
setShown(false);
}
function start(source: Hold["source"], x = 0, y = 0) {
end();
const timer = window.setTimeout(show, source === "key" ? KEY_HOLD : POINTER_HOLD);
hold.current = { source, shown: false, timer, x, y };
}
/** Ends any hold. Returns true when the panel had appeared. */
function end() {
const current = hold.current;
hold.current = null;
if (!current) return false;
window.clearTimeout(current.timer);
if (current.shown) hide();
return current.shown;
}
function keyDown(event: Key) {
if (event.key === "Escape" && hold.current) {
end();
return;
}
if (event.key !== " " || event.altKey || event.ctrlKey || event.metaKey) return;
event.preventDefault();
event.stopPropagation();
if (!event.repeat && !hold.current) start("key");
}
function keyUp(event: Key, el: HTMLElement) {
if (event.key !== " " || hold.current?.source !== "key") return;
event.preventDefault();
event.stopPropagation();
const wasShown = end();
if (!wasShown && document.activeElement === el && el.matches(ACTIVATES_ON_SPACE)) el.click();
}
// Hover plus Space: only while nothing else has focus, so Space is never taken from a focused control.
useEffect(() => {
if (!hovered) return;
function idle() {
const active = document.activeElement;
return !active || active === document.body;
}
function down(event: globalThis.KeyboardEvent) {
const el = trigger();
if (el && idle()) keyDown(event);
}
function up(event: globalThis.KeyboardEvent) {
const el = trigger();
if (el && idle()) keyUp(event, el);
}
document.addEventListener("keydown", down);
document.addEventListener("keyup", up);
return () => {
document.removeEventListener("keydown", down);
document.removeEventListener("keyup", up);
};
}, [hovered]);
useEffect(() => () => {
if (hold.current) window.clearTimeout(hold.current.timer);
}, []);
function fromTrigger(target: EventTarget) {
return target === trigger();
}
// The description is set on the element itself, not by cloning: when a server
// component passes the child, it arrives as a reference whose props cannot be read.
useEffect(() => {
const el = trigger();
if (!el) return;
const own = (el.getAttribute("aria-describedby") ?? "").split(" ").filter((id) => id && id !== hintId && id !== panelId);
el.setAttribute("aria-describedby", [...own, hintId, shown ? panelId : ""].filter(Boolean).join(" "));
});
return (
<span
{...rest}
ref={rootRef}
className={["eq-peek", className].filter(Boolean).join(" ")}
data-placement={placement}
onKeyDown={(event: KeyboardEvent<HTMLSpanElement>) => {
onKeyDown?.(event);
if (fromTrigger(event.target)) keyDown(event);
}}
onKeyUp={(event: KeyboardEvent<HTMLSpanElement>) => {
onKeyUp?.(event);
const el = trigger();
if (el && event.target === el) keyUp(event, el);
}}
onBlur={(event: FocusEvent<HTMLSpanElement>) => {
onBlur?.(event);
if (hold.current?.source === "key") end();
}}
onPointerEnter={(event: PointerEvent<HTMLSpanElement>) => {
onPointerEnter?.(event);
if (event.pointerType === "mouse") setHovered(true);
}}
onPointerLeave={(event: PointerEvent<HTMLSpanElement>) => {
onPointerLeave?.(event);
setHovered(false);
const el = trigger();
if (hold.current?.source === "pointer" || (hold.current && document.activeElement !== el)) end();
}}
onPointerDown={(event: PointerEvent<HTMLSpanElement>) => {
onPointerDown?.(event);
swallowClick.current = false;
const el = trigger();
if (!event.isPrimary || event.button !== 0 || !el || !el.contains(event.target as Node)) return;
start("pointer", event.clientX, event.clientY);
}}
onPointerMove={(event: PointerEvent<HTMLSpanElement>) => {
onPointerMove?.(event);
const current = hold.current;
if (current?.source !== "pointer" || current.shown) return;
if (Math.hypot(event.clientX - current.x, event.clientY - current.y) > SLOP) end();
}}
onPointerUp={(event: PointerEvent<HTMLSpanElement>) => {
onPointerUp?.(event);
if (hold.current?.source === "pointer" && end()) swallowClick.current = true;
}}
onPointerCancel={(event: PointerEvent<HTMLSpanElement>) => {
onPointerCancel?.(event);
if (hold.current?.source === "pointer") end();
}}
onClickCapture={(event: MouseEvent<HTMLSpanElement>) => {
onClickCapture?.(event);
if (!swallowClick.current) return;
swallowClick.current = false;
event.preventDefault();
event.stopPropagation();
}}
onContextMenu={(event: MouseEvent<HTMLSpanElement>) => {
onContextMenu?.(event);
if (hold.current?.source === "pointer") event.preventDefault();
}}
>
{children}
<span id={hintId} className="eq-peek-hint">
Hold Space to preview
</span>
<span
ref={panelRef}
id={panelId}
popover="manual"
role="tooltip"
aria-label={label}
className="eq-peek-panel"
>
{preview}
</span>
</span>
);
}components/equip/peek.css89 lines
@layer equip {
.eq-peek {
display: contents;
}
/* A long press on touch shows the preview, not the system callout. */
.eq-peek > :first-child {
-webkit-touch-callout: none;
}
.eq-peek-hint {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-peek-panel {
--_from: 0 4px;
position: fixed;
inset: auto;
margin: 0;
width: max-content;
max-width: min(20rem, calc(100vw - 2 * var(--eq-space-2)));
padding: var(--eq-space-4);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
box-shadow: var(--eq-shadow);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.45 var(--eq-font);
pointer-events: none;
opacity: 0;
scale: 0.96;
translate: var(--_from);
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-peek-panel[data-side="end"] {
--_from: -4px 0;
transform-origin: left top;
}
.eq-peek-panel[data-side="start"] {
--_from: 4px 0;
transform-origin: right top;
}
.eq-peek-panel[data-side="bottom"] {
--_from: 0 -4px;
transform-origin: left top;
}
.eq-peek-panel[data-side="top"] {
--_from: 0 4px;
transform-origin: left bottom;
}
.eq-peek-panel:popover-open {
display: block;
opacity: 1;
scale: 1;
translate: 0 0;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
@starting-style {
.eq-peek-panel:popover-open {
opacity: 0;
scale: 0.96;
translate: var(--_from);
}
}
}