Quickly navigate through the dashboard with your keyboard using the command menu.
A row of project cards where a click grows a circle from the exact click point until it floods the section and becomes the detail view — the photo and title fly into place, the circle's rim magnifies the page like a lens and presses the other cards back as it passes. Esc or the close button collapses it into the card.
Circle Expand Card is a row of project cards where clicking one grows a circle out of the exact point you clicked until it floods the section and becomes the project's detail view. The photo and title fly into their new positions, the circle's rim behaves like the edge of a glass lens — magnifying the page just outside it — and the other cards are physically pressed back as the rim sweeps past them. Escape or the close button collapses the whole thing back into the card it came from. Good for portfolios and project indexes.
Copy the gems/circle-expand-card-01/ folder — index.html,
assets/style.css, assets/script.js, assets/byq-brand.css,
assets/gem-frame.css and assets/medias/ — into one directory and open
index.html. No build step, no dependencies; everything is one
requestAnimationFrame tween.
Keep assets/byq-brand.css linked for the brand tokens. Each card carries a
data-tone of solar, lime or fg, which sets the --cec-flood and
--cec-ink custom properties used by that card's flood colour and its text.
Each card is a real button that points at its own dialog, so the whole thing
works from the keyboard and reads correctly to a screen reader.
<div class="byq-gem--circle-expand-card-01__stage">
<ul class="byq-gem--circle-expand-card-01__grid" role="list">
<li class="byq-gem--circle-expand-card-01__item">
<button class="byq-gem--circle-expand-card-01__card" type="button" data-tone="solar"
aria-haspopup="dialog" aria-controls="byq-gem--circle-expand-card-01-d1">
<span class="byq-gem--circle-expand-card-01__media">
<img class="byq-gem--circle-expand-card-01__img" src="assets/medias/01.webp" alt="" />
<span class="byq-gem--circle-expand-card-01__bloom" aria-hidden="true">
<span class="byq-gem--circle-expand-card-01__bloom-disc">
<span class="byq-gem--circle-expand-card-01__bloom-label">View</span>
</span>
</span>
</span>
<span class="byq-gem--circle-expand-card-01__meta">
<span class="byq-gem--circle-expand-card-01__title">Callbox</span>
<span class="byq-gem--circle-expand-card-01__index">01</span>
</span>
</button>
</li>
</ul>
</div>
<div class="byq-gem--circle-expand-card-01__lens" aria-hidden="true"></div>
<div class="byq-gem--circle-expand-card-01__rim" aria-hidden="true"></div>
<div class="byq-gem--circle-expand-card-01__dialog" id="byq-gem--circle-expand-card-01-d1"
data-tone="solar" role="dialog" aria-modal="true" hidden>…</div>The __lens and __rim layers sit above the cards and are empty in the markup
— the script fills the lens with a copy of the card row at runtime.
Tone is the only thing that changes per card, and it is done entirely with custom properties so the JavaScript never has to know about colour.
.byq-gem--circle-expand-card-01 [data-tone="solar"] {
--cec-flood: var(--byq-accent-solar);
--cec-ink: var(--byq-on-accent);
}
.byq-gem--circle-expand-card-01 [data-tone="lime"] {
--cec-flood: var(--byq-accent-lime);
--cec-ink: var(--byq-on-accent);
}The hover disc is a plain element that the script moves; CSS only handles it
growing when the card gains the is-bloom class, so the pointer tracking and
the reveal stay separate concerns.
.byq-gem--circle-expand-card-01__bloom-disc {
position: absolute;
background: var(--cec-flood);
color: var(--cec-ink);
transition: transform 0.5s var(--cec-ease);
}1. Follow the cursor with the hover disc. Each card keeps its own eased
position, chasing the pointer at 0.22 per frame, and the loop parks itself as
soon as nothing is moving.
b.x += (b.tx - b.x) * k;
b.y += (b.ty - b.y) * k;
b.el.style.transform = `translate3d(${b.x.toFixed(2)}px, ${b.y.toFixed(2)}px, 0)`;2. Grow a clip-path circle from the click point. farthest() measures the
distance to the furthest corner, so the circle is exactly big enough to flood
the section from wherever you clicked. If you click while the hover disc is
showing, the circle starts at the disc's position and radius instead — the
reveal continues from what was already on screen rather than jumping.
const circle = `circle(${r.toFixed(1)}px at ${cx.toFixed(1)}px ${cy.toFixed(1)}px)`;
[dialog, fly].forEach((el) => { el.style.clipPath = circle; });3. Fly the photo and title with FLIP. The card's media and title are measured, the dialog's hero and heading are measured, and clones are interpolated between the two rectangles. The title is the same text scaled up — because both share a font and line-height, the ratio of their widths is the scale factor.
const s = lerp(1, b.w / a.w, t);
el.style.transform = `…translate3d(…) scale(${s.toFixed(4)})`;
el.style.color = `color-mix(in srgb, var(--cec-ink) ${(inkT * 100).toFixed(1)}%, var(--byq-fg))`;4. Make the rim a real lens. An inert clone of the card row is scaled up about the circle's centre and then masked to a thin ring with a radial gradient. Because the band shows content from further in, magnified and pushed outward, it reads as the curved edge of a glass disc rather than a drawn outline.
const s = clamp(mid / Math.max(1, mid - shift), 1, 1.9);
ghost.style.transformOrigin = `${cx}px ${cy}px`;
ghost.style.transform = `scale(${s.toFixed(4)})`;
lens.style.maskImage = g;5. Press the other cards back. For each non-active card the script measures the gap from the rim to the nearest point of that card's rectangle, smoothsteps it, and uses it to push, shrink, blur and dim. The lens copy gets the same push but stays sharp, which is what sells the rim as clear glass.
const gap = Math.hypot(nx - cx, ny - cy) - r;
const k = clamp(1 - gap / RANGE);
const p = gap <= 0 ? 1 : k * k * (3 - 2 * k);6. Keep it a proper dialog. While open, the card row is marked inert,
focus moves to the close button and is trapped inside the dialog, Escape closes,
and focus returns to the card that opened it. A state machine
(idle/opening/open/closing) means a close pressed mid-open is queued
rather than tearing the animation in half. Under
prefers-reduced-motion the whole circle sequence is replaced with a short
cross-fade.
DUR (1150 opening, 1050 closing) — the length of the reveal in
milliseconds.RANGE and push (42) in paintCircle — how far ahead of the rim
the other cards start reacting, and how hard they are shoved. The blur(6px)
and brightness in the same block control the rest of the pressure.band (clamp(34 + r * 0.16, 34, 120)) — the thickness of the lens ring;
the 1.9 cap on s is its maximum magnification.r0 = 48 — the radius the circle inherits from the hover disc.data-tone on a card (and its dialog) between solar,
lime and fg, or add your own block setting --cec-flood and --cec-ink.assets/medias/. The dialog's __hero and __heading are the
flight targets, so keep those class names if you restructure it.