Swish UI
All componentsButtons and controls Free

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.

Found on