Bracket focus frame
Four corner brackets glide and resize to frame the hovered or focused option, then chase round as they land.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
import styles from "./demo.module.css";
const REGIONS = [
{ code: "FRA", city: "Frankfurt", ms: 18 },
{ code: "LHR", city: "London", ms: 24 },
{ code: "IAD", city: "Virginia", ms: 92 },
{ code: "SEA", city: "Seattle", ms: 141 },
{ code: "SIN", city: "Singapore", ms: 162 },
{ code: "SYD", city: "Sydney", ms: 274 },
];
const PAD = 4; // the frame sits this far outside the item it frames
export default function Demo() {
const labelId = useId();
const grid = useRef<HTMLDivElement>(null);
const frame = useRef<HTMLSpanElement>(null);
const items = useRef<(HTMLButtonElement | null)[]>([]);
const [selected, setSelected] = useState(0);
const [hover, setHover] = useState<number | null>(null);
const [focus, setFocus] = useState<number | null>(null);
const target = hover ?? focus ?? selected;
// Glide the shared frame to the target item, then replay the corner chase once it lands.
useLayoutEffect(() => {
const box = frame.current;
const area = grid.current;
if (!box || !area) return;
const place = () => {
const el = items.current[target];
if (!el) return;
box.style.transform = `translate(${el.offsetLeft - PAD}px, ${el.offsetTop - PAD}px)`;
box.style.width = `${el.offsetWidth + PAD * 2}px`;
box.style.height = `${el.offsetHeight + PAD * 2}px`;
};
place();
const raf = requestAnimationFrame(() => box.classList.add(styles.ready));
const land = setTimeout(() => {
box.classList.remove(styles.chase);
void box.offsetWidth; // restart the animation
box.classList.add(styles.chase);
}, 260);
const observer = new ResizeObserver(place);
observer.observe(area);
return () => { cancelAnimationFrame(raf); clearTimeout(land); observer.disconnect(); };
}, [target]);
const move = (i: number) => {
setSelected(i);
items.current[i]?.focus();
};
const keyDown = (e: KeyboardEvent) => {
const area = grid.current;
if (!area) return;
const cols = getComputedStyle(area).gridTemplateColumns.split(" ").length;
const n = REGIONS.length;
const i = focus ?? selected;
const next: Record<string, number> = {
ArrowRight: (i + 1) % n, ArrowLeft: (i - 1 + n) % n,
ArrowDown: (i + cols) % n, ArrowUp: (i - cols + n) % n, Home: 0, End: n - 1,
};
if (!(e.key in next)) return;
e.preventDefault();
move(next[e.key]);
};
return (
<div className={styles.root}>
<div className={styles.panel}>
<div className={styles.head}>
<p id={labelId} className={styles.title}>Primary region</p>
<p className={styles.hint}>Latency from you</p>
</div>
<div
ref={grid}
className={styles.grid}
role="radiogroup"
aria-labelledby={labelId}
onKeyDown={keyDown}
onPointerLeave={() => setHover(null)}
>
{REGIONS.map((r, i) => (
<button
key={r.code}
ref={(el) => { items.current[i] = el; }}
type="button"
role="radio"
aria-checked={i === selected}
tabIndex={i === selected ? 0 : -1}
className={styles.item}
onPointerEnter={(e) => { if (e.pointerType !== "touch") setHover(i); }}
onFocus={() => setFocus(i)}
onBlur={() => setFocus(null)}
onClick={() => move(i)}
>
<span className={styles.city}>{r.city}</span>
<span className={styles.meta}>{r.code} <b>{r.ms} ms</b></span>
<span className={styles.check} aria-hidden="true">
<svg viewBox="0 0 16 16"><path d="M4 8.5l2.5 2.5 5.5-6" /></svg>
</span>
</button>
))}
<span ref={frame} className={styles.frame} aria-hidden="true">
<i /><i /><i /><i />
</span>
</div>
<p className={styles.foot} aria-live="polite">
Deploying to <b>{REGIONS[selected].city}</b>
</p>
</div>
</div>
);
}
demo.module.css
.root {
container-type: size; width: 100%; height: 100%; display: grid; place-items: center; padding: 16px;
box-sizing: border-box; background: var(--surface);
}
.panel { width: min(520px, 100%); display: grid; gap: 12px; }
.panel p { margin: 0; }
.head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding-inline: 2px; }
.title { font-size: 14px; font-weight: 700; }
.hint { font-size: 12px; color: var(--muted); }
.foot { font-size: 13px; color: var(--muted); padding-inline: 2px; }
.foot b { color: var(--ink); font-weight: 650; }
.grid { position: relative; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.item {
position: relative; display: grid; gap: 3px; align-content: center; min-height: 58px; padding: 10px 12px;
border-radius: 10px; border: 1px solid var(--line); background: var(--white); color: var(--ink);
font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;
transition: border-color .25s, background-color .25s;
}
.item:focus-visible { outline: none; }
.item[aria-checked="true"] { border-color: var(--accent-soft); background: var(--accent-pale); }
.city { font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item[aria-checked="true"] .city { padding-right: 18px; }
.meta { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.meta b { font-weight: 600; margin-left: 4px; }
.check {
position: absolute; top: 9px; right: 9px; display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%;
background: var(--accent-text); color: var(--white); opacity: 0; scale: .4; transition: opacity .25s, scale .35s var(--out);
}
.item[aria-checked="true"] .check { opacity: 1; scale: 1; }
.check svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
/* One shared frame: four corner brackets that glide and resize between items. */
.frame { position: absolute; left: 0; top: 0; pointer-events: none; }
.ready { transition: transform .38s var(--out), width .38s var(--out), height .38s var(--out); }
.frame i {
--dx: -1; --dy: -1; position: absolute; width: 12px; height: 12px; border: 0 solid var(--ink);
left: 0; top: 0; border-left-width: 2px; border-top-width: 2px; border-top-left-radius: 5px;
}
.frame i:nth-child(2) { --dx: 1; --dy: -1; left: auto; right: 0; border-left-width: 0; border-right-width: 2px; border-radius: 0 5px 0 0; }
.frame i:nth-child(3) { --dx: 1; --dy: 1; left: auto; top: auto; right: 0; bottom: 0; border-left-width: 0; border-top-width: 0; border-right-width: 2px; border-bottom-width: 2px; border-radius: 0 0 5px 0; }
.frame i:nth-child(4) { --dx: -1; --dy: 1; top: auto; bottom: 0; border-top-width: 0; border-bottom-width: 2px; border-radius: 0 0 0 5px; }
/* On landing each corner in turn, clockwise, steps out and flashes the accent: a quick chase round the frame. */
.chase i { animation: chase .42s var(--out) both; }
.chase i:nth-child(2) { animation-delay: 70ms; }
.chase i:nth-child(3) { animation-delay: 140ms; }
.chase i:nth-child(4) { animation-delay: 210ms; }
@keyframes chase {
40% { translate: calc(var(--dx) * 4px) calc(var(--dy) * 4px); border-color: var(--accent); }
}
@container (max-width: 340px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
}
@container (max-height: 300px) {
.panel { gap: 8px; }
.item { min-height: 50px; padding-block: 7px; }
.grid { gap: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.ready { transition: none; }
.chase i { animation: none; }
.check { transition: none; }
}
Use it for
- Option grids such as regions, plans, templates or models
- Keyboard heavy pickers where focus should be obvious
- Developer tools that want a precise, technical feel
Avoid
- Long scrolling lists, where the frame travels too far
- Items of very different sizes packed tightly together
How it works
One absolutely positioned frame holds four bordered corner spans. A layout effect reads the target item's offsetLeft, offsetTop and size and writes transform, width and height, which CSS transitions with the shared easing, so the corners glide and resize in a single move. The target is the hovered item, else the focused one, else the selected one. Shortly after each move a class is removed and re added to replay a staggered keyframe that steps each corner out in turn.
Accessibility
The grid is a radiogroup with real radio buttons and a roving tabindex: Tab enters at the selected region and the arrow keys, Home and End move through the grid. The choice is announced in a polite live region. Reduced motion jumps the frame with no glide or chase.