The 3 files this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.
Read it first
Install kit: "Blur image card"
Library page: https://astro.baysixmedia.com/components/blur-image-card/
Files: 3 required, 1 supporting
Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/components/BlurImageCard.astro =====
---
/**
* BlurImageCard — a card whose colour field is its own image, blurred past
* recognition, and which turns over on a horizontal 3D flip to show its
* details on the back.
*
* One self-contained file. No imports, no global stylesheet, no npm packages,
* no webfonts, no bundled images and no network requests of its own: drop it
* into any Astro project and render it. The only image it ever loads is the
* one you pass in `src`.
*
* The decisions that carry the weight, and why they are shaped this way:
*
* - **JavaScript is the only source of truth for the flip.** There is no
* `:hover` rule that turns the card over. A CSS hover rule and an
* `aria-expanded` attribute are two state machines that agree right up
* until they do not, and the version this was built from could show the
* back face while still reporting `aria-expanded="false"`. Pointer entry
* is an *input* here, handled in script, exactly like a click or a key.
*
* - **Real controls, not a focusable card.** The front carries a real
* `<button>` — the action prompt itself — and the back carries a real
* return control, labelled and placed to mirror it, so the same spot on
* the card takes you in and back out again. Anything you put in a slot is
* ordinary markup beside those buttons, never nested inside one.
*
* - **Only the face you can see is reachable.** The hidden face is
* `aria-hidden`, `inert`, and — after the flip settles — `visibility:
* hidden`, which is what removes it from the tab order in every browser
* and, crucially, with no script running at all. Whatever you put in the
* slots is covered by the same three, because they are applied to the
* face rather than to the elements this file happens to know about.
*
* - **The blurred field is an `<img>`, not a `url()`.** A caller-supplied
* string never reaches a CSS custom property, so there is nothing to
* escape and nothing to break out of. The element is decorative, oversized
* past the face edges so the blur has real pixels to sample at the rim,
* and blurred, desaturated and scaled to the same recipe as the original.
*
* Progressive enhancement, stated plainly: the back-face reveal needs script.
* Without it the front face renders and reads normally, and neither the open
* control nor the close control is shown, because a control that cannot work
* is worse than no control.
*
* Several instances can share a page: every query is scoped to the instance
* root, ids are minted per instance, and all state lives in the controller's
* closure.
*/
import type { ImageMetadata } from "astro";
export interface BlurImageCardProps {
/**
* Optional image, blurred behind the glass on both faces.
*
* When present, it becomes the enlarged, unrecognisable colour source for
* the frosted material. Omit `src` entirely and the CSS colour field supplies
* the same effect without rendering or fetching an image.
*/
src?: string | ImageMetadata;
/** Main heading on the front face. */
title: string;
/** Small number or eyebrow shown at the top of the front face. */
number?: string;
/** Front-face prompt. This is the visible label of the opening button. */
actionLabel?: string;
/** Copy shown on the back face. */
description?: string;
/** Back-face prompt. This is the visible label of the returning button. */
returnLabel?: string;
/** Accessible name for the card as a whole. Defaults to the title. */
ariaLabel?: string;
/**
* Accessible name for the opening button. Defaults to the action label
* followed by the title, so several cards on one page are told apart.
*/
openLabel?: string;
/**
* Accessible name for the returning button. Defaults to the return label
* followed by the title, on the same reasoning as `openLabel` — and it
* keeps the visible word inside the accessible name, which is what voice
* control needs to act on what it can see.
*/
closeLabel?: string;
/**
* Blur radius of the image behind the glass, in pixels. Clamped to 0–120;
* a bad value falls back to 30. This is the image blur, not the glass
* blur — that one is `--bic-glass-blur`, because it is a property of the
* surface rather than of the picture.
*/
blur?: number;
/** Flip duration in milliseconds. Clamped to 0–5000; a bad value falls back to 850. */
duration?: number;
/** Optional extra class for project-specific layout. */
class?: string;
/** Element id. Also seeds the internal ids instead of a random value. */
id?: string;
}
interface Props extends BlurImageCardProps {}
const {
src,
title,
number = "01",
actionLabel = "View details",
description = "Add the supporting details for this card.",
returnLabel = "Return",
ariaLabel,
openLabel,
closeLabel,
blur = 30,
duration = 850,
class: className,
id,
}: Props = Astro.props;
/*
* Numeric props are normalised rather than trusted. They are written into an
* inline `style` as CSS custom properties, so a NaN, an Infinity or a negative
* would either produce an invalid declaration or a genuinely unpleasant render
* — a negative blur radius is invalid CSS, and a 40-second flip is a card that
* appears broken. Both are clamped into a range that is always renderable.
*/
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
const safeBlur = clampNumber(blur, 30, 0, 120);
const safeDuration = clampNumber(duration, 850, 0, 5000);
/*
* Image data is treated as untrusted: it typically arrives from a CMS or a
* content collection and becomes an attribute on every render.
*/
/** Control characters are stripped first: "java\tscript:" is a scheme too. */
function stripControls(value: string): string {
return [...value]
.filter((character) => {
const code = character.codePointAt(0) ?? 0;
return code > 31 && code !== 127;
})
.join("");
}
/**
* Only the schemes that can execute are refused.
*
* `data:` is allowed, because an inline SVG is a legitimate image.
*/
function safeSrc(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const raw = value.trim();
if (raw === "") return undefined;
const bare = stripControls(raw).trim();
if (bare === "") return undefined;
if (/^(javascript|vbscript):/i.test(bare)) return undefined;
return bare;
}
/* Both string paths and an imported Astro asset. An asset also carries its
intrinsic size, which lets the browser reserve the right box. */
const metadata = src && typeof src === "object" ? (src as ImageMetadata) : undefined;
const imageSrc = safeSrc(metadata ? metadata.src : src);
const imageWidth = Number.isFinite(metadata?.width) ? metadata!.width : undefined;
const imageHeight = Number.isFinite(metadata?.height) ? metadata!.height : undefined;
const cardLabel = ariaLabel ?? title;
const openAriaLabel = openLabel ?? `${actionLabel}: ${title}`;
const closeAriaLabel = closeLabel ?? `${returnLabel}: ${title}`;
/*
* Random rather than sequential: a module-level counter is shared by every
* render in the same server process, which is fine for one page and wrong the
* moment two pages are built in parallel and compared.
*/
const uid = id ?? `bic-${Math.random().toString(36).slice(2, 9)}`;
const backId = `${uid}-back`;
---
<article
class:list={["blur-image-card", className]}
id={id}
data-blur-image-card
aria-label={cardLabel}
style={`--bic-blur: ${safeBlur}px; --bic-duration: ${safeDuration}ms;`}
>
<div class="bic-inner" data-bic-inner>
{/*
Faces are plain divs rather than sections: the <article> is the one
named container this card needs, and two extra named regions inside
it would be two more things to page past in a screen reader for no
gain.
*/}
<div class="bic-face bic-face--front" data-bic-face="front" aria-hidden="false">
{imageSrc && (
<img
class="bic-image"
src={imageSrc}
alt=""
aria-hidden="true"
draggable="false"
decoding="async"
width={imageWidth}
height={imageHeight}
/>
)}
<span class="bic-tint" aria-hidden="true"></span>
<span class="bic-color-ease" aria-hidden="true"></span>
<div class="bic-content bic-content--front">
<slot name="front">
<span class="bic-number">{number}</span>
<h3 class="bic-title">{title}</h3>
</slot>
</div>
{/*
Hidden until the controller reveals it. Without script the flip
cannot happen, and an action prompt that does nothing is a lie
told in the user's own interface.
*/}
<button
class="bic-action"
type="button"
data-bic-open
hidden
aria-expanded="false"
aria-controls={backId}
aria-label={openAriaLabel}
>
<span class="bic-action-text">{actionLabel}</span>
{/* An arrow, not a circular one: this opens the card, it does not
reload anything, and the old refresh glyph said the wrong word. */}
<svg class="bic-icon" aria-hidden="true" viewBox="0 0 24 24" width="18" height="18">
<path d="M4 12h14M12.5 6.5 18 12l-5.5 5.5" />
</svg>
</button>
</div>
<div
class="bic-face bic-face--back"
id={backId}
data-bic-face="back"
aria-hidden="true"
inert
>
{imageSrc && (
<img
class="bic-image"
src={imageSrc}
alt=""
aria-hidden="true"
draggable="false"
decoding="async"
width={imageWidth}
height={imageHeight}
/>
)}
<span class="bic-tint" aria-hidden="true"></span>
<span class="bic-color-ease" aria-hidden="true"></span>
{/*
Its own scroll container. A long description on a short card has
to go somewhere, and "out through the rounded corner" is not an
option on a face that is also a 3D transform target.
*/}
<div class="bic-content bic-content--back">
<slot name="back">
<p class="bic-description">{description}</p>
</slot>
</div>
{/*
The way back, in the same place the way in was. Hidden until the
controller reveals it, for the same reason the action prompt is:
without script there is no flip to undo.
*/}
<button
class="bic-close"
type="button"
data-bic-close
hidden
aria-label={closeAriaLabel}
>
{/* A counter-clockwise circular arrow: the card turns back the way
it came. The front's straight arrow goes forward, this one
retraces it, and neither is the refresh glyph. */}
<svg class="bic-icon" aria-hidden="true" viewBox="0 0 24 24" width="18" height="18">
<path d="M2 4v6h6" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L2 10" />
</svg>
<span class="bic-close-text">{returnLabel}</span>
</button>
</div>
</div>
</article>
<script>
/**
* BlurImageCard controller.
*
* One closure per instance. Every query is scoped to that instance's root,
* every listener carries an AbortController signal, and the whole thing is
* torn down before a client-side navigation swaps the document.
*
* This is the only thing that decides whether a card is open. The
* stylesheet has no `:hover` rule that turns the card over — pointer entry
* is an input handled here, alongside clicks and keys, so the visual state,
* `aria-expanded`, `aria-hidden` and `inert` cannot disagree.
*/
interface Instance {
destroy(): void;
}
function initBlurImageCard(root: HTMLElement): Instance | undefined {
/* Idempotent: booting twice is a no-op, not a second set of listeners. */
if (root.dataset.bicReady === "") return undefined;
const frontQuery = root.querySelector<HTMLElement>('[data-bic-face="front"]');
const backQuery = root.querySelector<HTMLElement>('[data-bic-face="back"]');
const openQuery = root.querySelector<HTMLButtonElement>("[data-bic-open]");
const closeQuery = root.querySelector<HTMLButtonElement>("[data-bic-close]");
if (!frontQuery || !backQuery || !openQuery || !closeQuery) return undefined;
/* Re-bound as non-nullable consts. The guard above narrows the four
queries, but TypeScript does not carry a narrowing into a hoisted
function declaration, and every handler below is inside one. */
const front = frontQuery;
const back = backQuery;
const openControl = openQuery;
const closeControl = closeQuery;
root.dataset.bicReady = "";
const controller = new AbortController();
const { signal } = controller;
/*
* The interaction state model, in full.
*
* open what the card is showing right now
* pinned the visitor asked for it, so a pointer leaving
* does not undo it
* pointerInside a fine pointer is currently within the card's box
* hoverSuppressed a deliberate close happened with the pointer still
* inside, so hover must not re-open until the pointer
* has actually left and come back
*
* `hoverSuppressed` is the one that is easy to leave out and impossible
* to live without. Close a card with the mouse still over it and hover
* immediately re-opens it, so the close button appears not to work; move
* the mouse a little and it flips again. The flag is cleared by
* `pointerleave` and by nothing else, which is what makes "closed" mean
* closed until the visitor genuinely leaves.
*/
let open = false;
let pinned = false;
let pointerInside = false;
let hoverSuppressed = false;
/*
* Hover intent. Opening waits longer than closing: crossing a card on
* the way somewhere else should not flip it, but a card you have left
* should settle back promptly.
*/
const OPEN_DELAY = 150;
const CLOSE_DELAY = 100;
let openTimer: number | undefined;
let closeTimer: number | undefined;
function cancelTimers() {
if (openTimer !== undefined) window.clearTimeout(openTimer);
if (closeTimer !== undefined) window.clearTimeout(closeTimer);
openTimer = undefined;
closeTimer = undefined;
}
/*
* Evaluated per event rather than once at boot: a laptop with a
* touchscreen and a trackpad can change its answer, and a card that
* decided at load time would be wrong for the rest of the page's life.
*/
const finePointer = () =>
window.matchMedia("(hover: hover) and (pointer: fine)").matches;
/**
* Expose exactly one face.
*
* Applied to the face, never to the individual controls: whatever a
* caller puts in the `front` and `back` slots is then covered by the
* same rule, with no list of element types to keep up to date.
*/
function expose(face: HTMLElement, visible: boolean) {
face.setAttribute("aria-hidden", visible ? "false" : "true");
if (visible) face.removeAttribute("inert");
else face.setAttribute("inert", "");
}
function focusControl(target: HTMLButtonElement) {
target.focus({ preventScroll: true });
}
/**
* Whether someone is reading this card with the keyboard.
*
* `:focus-visible` rather than plain focus, and the distinction is the
* whole point. Closing the card returns focus to the front control,
* because the control that had focus is about to become inert — so after
* a mouse user closes a card, focus is sitting inside it. Treating that
* as "someone is reading this" would wedge the card open for the rest of
* the session: every later hover would open it and no pointer exit would
* ever close it again.
*/
function keyboardFocusInside(): boolean {
const active = document.activeElement;
if (!active || !root.contains(active)) return false;
try {
return active.matches(":focus-visible");
} catch {
/* No :focus-visible support: keep the safe answer, which is to
leave the card alone rather than close it under someone. */
return true;
}
}
/**
* The single state transition.
*
* `moveFocus` is for deliberate activations — a click, a key, Escape.
* Focus also moves when it was inside the face about to be made inert,
* because inerting an ancestor of the focused element drops focus to
* the body, and losing your place is not an acceptable outcome of
* moving a mouse.
*/
function setOpen(next: boolean, moveFocus: boolean) {
if (next === open) {
if (moveFocus) focusControl(next ? closeControl : openControl);
return;
}
open = next;
const showing = open ? back : front;
const hiding = open ? front : back;
const heldFocus = hiding.contains(document.activeElement);
root.classList.toggle("is-open", open);
openControl.setAttribute("aria-expanded", open ? "true" : "false");
expose(showing, true);
expose(hiding, false);
if (moveFocus || heldFocus) focusControl(open ? closeControl : openControl);
}
/*
* Reversing mid-flip is the browser's job, and it already does it well.
*
* CSS transitions carry a reversing shortening factor: interrupt a
* rotation and the return trip is scaled to the progress actually made,
* so a card caught 22 degrees in comes back in about 127ms rather than
* replaying the whole 850. Measured, not assumed.
*
* An earlier version of this file set an inline `transition-duration`
* to do that scaling by hand. It was worse than doing nothing: changing
* the duration on a running transition discards the shortening factor,
* and the same 22 degree reversal collapsed in 24ms — a snap. The fix
* was to delete it. There is nothing here for that reason.
*/
/* Only revealed once there is a controller behind them. */
openControl.hidden = false;
closeControl.hidden = false;
/* Sync to the served markup, in case the two ever disagree. */
expose(front, true);
expose(back, false);
openControl.setAttribute("aria-expanded", "false");
root.classList.remove("is-open");
openControl.addEventListener(
"click",
() => {
cancelTimers();
pinned = true;
hoverSuppressed = false;
setOpen(true, true);
},
{ signal },
);
/**
* Closing is deliberate, and it has to stick.
*
* Unpinning alone is not enough: the pointer is almost always still
* over the card when the close button is clicked, and hover would take
* it straight back open. Suppressing hover until the pointer has left
* is what makes the button do what it says.
*/
function closeDeliberately() {
cancelTimers();
pinned = false;
if (pointerInside) hoverSuppressed = true;
setOpen(false, true);
}
closeControl.addEventListener("click", closeDeliberately, { signal });
/*
* Hover is a mouse affordance and nothing else. `pointerenter` fires
* for a touch tap too, so the pointer type is checked as well as the
* media query — otherwise a tap would open the card through this path
* and the button's own click would immediately look like a no-op.
*/
root.addEventListener(
"pointerenter",
(event) => {
if (event.pointerType !== "mouse" || !finePointer()) return;
pointerInside = true;
/* Changed direction: whatever was pending is no longer wanted. */
if (closeTimer !== undefined) window.clearTimeout(closeTimer);
closeTimer = undefined;
if (hoverSuppressed || open || openTimer !== undefined) return;
openTimer = window.setTimeout(() => {
openTimer = undefined;
if (hoverSuppressed) return;
setOpen(true, false);
}, OPEN_DELAY);
},
{ signal },
);
root.addEventListener(
"pointerleave",
(event) => {
if (event.pointerType !== "mouse" || !finePointer()) return;
pointerInside = false;
/*
* The pointer has genuinely left, so a deliberate close has run
* its course and hover is allowed to work again. This is the
* only place the suppression is lifted.
*/
hoverSuppressed = false;
if (openTimer !== undefined) window.clearTimeout(openTimer);
openTimer = undefined;
/* A deliberate opening outlives the pointer, and so does a
card someone is currently reading with the keyboard. */
if (pinned || keyboardFocusInside() || !open) return;
if (closeTimer !== undefined) return;
closeTimer = window.setTimeout(() => {
closeTimer = undefined;
if (pinned || pointerInside || keyboardFocusInside()) return;
setOpen(false, false);
}, CLOSE_DELAY);
},
{ signal },
);
/*
* Pinning with a mouse.
*
* On a fine-pointer device the pointer reaches the card before the click
* does: the card is already turning, and the front control is no longer
* under the cursor by the time the button goes down. Without this,
* `pinned` would be reachable by touch and by keyboard only, and a mouse
* user could never keep a card open after moving away from it.
*
* It only ever pins a card that is already open. It cannot open, close or
* toggle anything, so the <article> stays a container rather than
* becoming a simulated button — there is no role, no tab stop and no
* keyboard surface added here.
*/
root.addEventListener(
"click",
(event) => {
if (!open || pinned) return;
const target = event.target as Element | null;
/* The card's own controls, and any link a slot put on the face,
own their own clicks. */
if (target?.closest("a, [data-bic-open], [data-bic-close]")) return;
cancelTimers();
pinned = true;
},
{ signal },
);
root.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || !open) return;
event.stopPropagation();
closeDeliberately();
},
{ signal },
);
function destroy() {
/* Back to exactly the state the markup was served in, so a re-used
element cannot inherit an open card from the previous route.
The ready flag goes first, which also returns the back face to
`visibility: hidden` — the served state for a page with no
controller running on it. */
delete root.dataset.bicReady;
cancelTimers();
root.classList.remove("is-open");
expose(front, true);
expose(back, false);
openControl.setAttribute("aria-expanded", "false");
openControl.hidden = true;
closeControl.hidden = true;
open = false;
pinned = false;
pointerInside = false;
hoverSuppressed = false;
controller.abort();
}
/*
* Astro replaces the document on a client-side navigation. Detach every
* listener, return the card to its served state, and clear the ready
* flag so a re-used element can boot again.
*/
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
return { destroy };
}
const boot = () =>
document
.querySelectorAll<HTMLElement>("[data-blur-image-card]")
.forEach(initBlurImageCard);
/* Astro's own <script> is a deferred module, so the DOM is already parsed
by the time this runs. The readyState check is for the case where this
file is copied into a project that inlines it some other way. */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
/* Fires on every view transition in a project using <ClientRouter />, and
never at all in one that does not. Registered once per module, so it
cannot accumulate. */
document.addEventListener("astro:page-load", boot);
</script>
<style>
.blur-image-card {
/* ---- overridable tokens -------------------------------------------
Set any of these on the element, from a class, an id or a wrapper
rule, to retune the component without touching this file.
`--bic-blur` and `--bic-duration` are also written inline from the
`blur` and `duration` props, and an inline value wins, so override
them in CSS only on cards that do not set the matching prop. Both
have a derived twin below that IS overridable from CSS. */
--bic-blur: 30px;
--bic-duration: 850ms;
/*
* Symmetric by construction: (x2, y2) is (1 - x1, 1 - y1), so the curve
* is its own 180-degree rotation about (0.5, 0.5) and opening and
* closing are the same motion in reverse.
*
* The non-zero y1 is the important part, and it is there for a
* perceptual reason rather than a stylistic one. Chromium re-samples a
* backdrop-filtered layer as soon as the rotation is non-zero, so the
* surface responds on the very first transformed frame. With a curve
* that starts at zero velocity (0.45, 0, 0.55, 1) that frame is only
* 0.21 degrees, which moves the card's edges by 0.19px — invisible. The
* result is a surface that changes while the card still looks
* stationary, and it reads as a colour flash before the flip.
*
* Measured over 60Hz frames, averaged across three runs:
*
* (0.45, 0, 0.55, 1) frame 1: 0.21 deg / 0.19px frame 2: 0.65 deg / 0.58px
* (0.42, 0.05, 0.58, 0.95) frame 1: 0.79 deg / 0.71px frame 2: 1.65 deg / 1.50px
* (0.40, 0.08, 0.60, 0.92) frame 1: 1.14 deg / 1.03px frame 2: 2.27 deg / 2.06px
* (0.38, 0.10, 0.62, 0.90) frame 1: 1.48 deg / 1.34px frame 2: 2.87 deg / 2.61px
*
* This is the gentlest of them that already moves the card by a whole
* pixel on the same frame the surface first responds, so the two are
* perceived together. The duration is unchanged.
*/
--bic-ease: cubic-bezier(0.4, 0.08, 0.6, 0.92);
/*
* The card is a card, not a section. Left to `width: 100%` it grows to
* whatever it is dropped into, and a square ratio then turns a wide
* page into one enormous tile. This is the standalone maximum; inside
* a grid column narrower than it, `min()` hands the column back.
*/
--bic-max-width: 22rem;
--bic-radius: clamp(1.25rem, 8cqi, 2rem);
--bic-mobile-ratio: 4 / 3;
--bic-wide-ratio: 1 / 1;
--bic-wide-from: 48rem;
--bic-pad: clamp(1rem, 7cqi, 1.75rem);
--bic-ink: #fff;
/* ---- blurred-media frosted glass ---------------------------------
* A dark translucent pane over an internal blurred colour field. The
* colour source is a child of each face, so it rotates with the card and
* never asks Chromium to re-sample the page backdrop mid-flip.
*/
--bic-glass: linear-gradient(
145deg,
rgb(18 27 48 / 0.7) 0%,
rgb(9 16 31 / 0.82) 100%
);
/*
* An optional supplied image is enlarged, darkened and blurred until only
* broad colour remains. The CSS colour field provides a fallback when no
* image is supplied.
*/
--bic-image-opacity: 0.72;
--bic-border-opacity: 0.24;
--bic-border: rgb(130 155 190 / var(--bic-border-opacity));
/*
* Directional, because real glass is not lit equally on four sides.
* The light is up and to the left, so the top and left edges catch it
* and the right and bottom fall into shadow. One offset inset does both
* halves of each pair, which is why there are two lines rather than
* four.
*/
--bic-shadow:
inset 0 1px 0 rgb(190 210 235 / 0.07),
inset 1px 1px 0 rgb(150 180 215 / 0.09),
inset -1px -1px 0 rgb(3 10 24 / 0.2),
0 24px 60px rgb(2 7 18 / 0.22);
/*
* Environmental light, not a painted-on cloud.
*
* A tight circle near the corner reads as a white blob stuck to the
* card. A wide, shallow ellipse anchored off the top edge reads as a
* window reflected in the surface: it spreads along part of the top,
* falls away early, and is gone well before the middle.
*/
--bic-reflection-opacity: 0.035;
--bic-reflection: radial-gradient(
120% 48% at 16% -12%,
rgb(205 220 240 / var(--bic-reflection-opacity)),
rgb(205 220 240 / 0.008) 46%,
transparent 70%
);
/*
* A wash, not a coat. The surface has to stay see-through, so the tint
* only steadies the contrast under the text: a little light on the
* front, a little more shade on the back, where the description is
* long enough to need it.
*/
/*
* ONE tint, used by both faces.
*
* The front and back used to carry different washes — white on the
* front, dark purple on the back — and that was the whole of the
* colour change people saw at the flip's midpoint: the card swapped
* material, not just content, exactly at the handoff. Measured over a
* flat ground, the back rendered up to 18 levels darker than the front
* at the same screen position; equalising this token took that
* difference to zero at every sampled point.
*
* If you retune it, retune it here. There is deliberately no per-face
* override: the two faces are the same pane of glass seen from either
* side, and only their content is allowed to differ.
*/
--bic-face-tint-opacity: 0;
--bic-face-tint: linear-gradient(
145deg,
rgb(255 255 255 / var(--bic-face-tint-opacity)),
rgb(255 255 255 / calc(var(--bic-face-tint-opacity) * 0.35))
);
--bic-ring: currentColor;
--bic-control-size: 44px;
--bic-perspective: 75rem;
/*
* The duration the motion actually uses. Separate from the public
* token on purpose: the token is written inline from the prop, and an
* inline declaration cannot be overridden from a stylesheet — so a
* reduced-motion rule that reassigned `--bic-duration` would silently
* do nothing on every card that passed the prop.
*/
--bic-motion-duration: var(--bic-duration);
/* The same arrangement for the blur: `--bic-blur` carries the prop and
cannot be reached from a stylesheet, so this is the hook a consumer
actually overrides. */
--bic-image-blur: var(--bic-blur);
/* How far the image is pushed past each face edge before it is blurred. */
--bic-bleed: calc(var(--bic-image-blur) * 1.4);
box-sizing: border-box;
container-type: inline-size;
position: relative;
display: block;
/*
* `min()` rather than a plain max-width so the two cases are one rule:
* standing alone it stops at --bic-max-width, and in a column narrower
* than that it simply fills the column. `margin-inline: auto` centres
* it either way, including inside a grid cell.
*/
width: min(100%, var(--bic-max-width));
margin-inline: auto;
aspect-ratio: var(--bic-mobile-ratio);
color: var(--bic-ink);
perspective: var(--bic-perspective);
-webkit-tap-highlight-color: transparent;
}
/* `:where` keeps specificity at zero, so a destination project's own rules
still win where it means them to. */
.blur-image-card :where(*, *::before, *::after) {
box-sizing: border-box;
}
.bic-inner {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transition: transform var(--bic-motion-duration) var(--bic-ease);
will-change: transform;
}
.blur-image-card.is-open .bic-inner {
transform: rotateY(180deg);
}
/*
* The glass.
*
* The fixed neutral gradient does not sample the page behind the card, so
* its colour remains attached to the rotating face for the complete flip.
* Reflection, directional edges and inset shadows provide the glass cues.
*/
.bic-face {
isolation: isolate;
position: absolute;
inset: 0;
padding: var(--bic-pad);
border: 1px solid var(--bic-border);
border-radius: var(--bic-radius);
/*
* `clip-path` as well as `overflow`, and this is a bug fix rather than
* belt and braces.
*
* A rounded `overflow` clip is applied to the face's own painting, but
* the backdrop-filtered layer is composited separately and, once the
* face is rotated in 3D, Chromium clips that layer to the border-box
* *rectangle* instead of to its rounded corners. The filtered backdrop
* then paints into the square corner while the border still traces the
* curve, which is the wedge that showed at the lower-right through the
* middle of every flip.
*
* Isolated and confirmed: with every other layer disabled, the border,
* the glass gradient, the image, the tint, the reflection and both
* shadows all stayed inside the rounded corner at 60 degrees, and only
* the backdrop-filtered region escaped it. Adding this one declaration
* put it back inside, exactly on the border.
*
* `clip` rather than `hidden`: the face never scrolls, and `clip` does
* not create a scroll container. The back face's own copy still
* scrolls, because that is a separate element with its own overflow.
*/
overflow: clip;
clip-path: inset(0 round var(--bic-radius));
background: var(--bic-glass);
box-shadow: var(--bic-shadow);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
/*
* No transition of any kind on a face. Only `.bic-inner` animates, and
* only its transform: a face that transitioned its own background,
* filter, tint or shadow would change material mid-rotation, which is
* exactly the class of defect this component has already been bitten
* by once.
*/
transition: none;
}
.bic-face--front {
display: grid;
grid-template-rows: 1fr auto 1fr;
/*
* AN IDENTITY TRANSFORM, AND IT IS LOAD-BEARING. Removing it breaks the
* card in Firefox.
*
* Firefox only backface-culls an element that carries a transform of
* its own. A child that merely inherits the parent's 3D orientation
* through `transform-style: preserve-3d` is treated as an ordinary flat
* layer, and `backface-visibility: hidden` on it is ignored. The back
* face was never affected, because it already declares
* `transform: rotateY(180deg)`; the front face declared nothing, so
* once the card was open its reversed copy painted straight through the
* back face — mirrored label, mirrored statement, mirrored control,
* over the top of the explanation.
*
* Measured in Firefox 156 on the built page, not inferred. The other
* candidates were eliminated one at a time in the live page: switching
* off `backdrop-filter`, `clip-path`, `overflow: clip`, `isolation` and
* the inner element's `will-change` each left the bleed exactly as it
* was. Adding this one declaration cleared it.
*
* `rotateY(0deg)` rather than `translateZ(0)`: it is the front face's
* true orientation stated explicitly, it is the exact mirror of what
* the back face declares, and it moves nothing on any axis, so the two
* faces stay coplanar and no depth-sorting question is introduced.
*
* Chromium and WebKit do not need it and are unchanged by it: the face
* is already a stacking context (`isolation: isolate`) and already a
* containing block for its absolutely positioned children, so a
* transform adds neither.
*
* IT IS THE DECLARATION THAT MATTERS, NOT THE MATRIX. Firefox computes
* this to `matrix(1, 0, 0, 1, 0, 0)` — the same identity it reports for
* a face with no transform at all — and still culls. So this cannot be
* replaced by "an equivalent value"; there is no equivalent value, only
* the presence or absence of the property.
*
* The minifier shortens it to `rotateY(0)`. That is valid — the
* Transforms grammar is `rotateY( [ <angle> | <zero> ] )` — and it was
* checked on the BUILT stylesheet, not just in source: Firefox parses
* the shortened form and the card is clean. Do not "correct" it back to
* `0deg` in `dist/`.
*/
transform: rotateY(0deg);
}
/*
* The mirror of the front: copy in the flexible row, control in the fixed
* one at the bottom. The two faces then put their control on the same line
* of the card, so the flip turns the card over rather than moving the
* thing you are about to press.
*/
.bic-face--back {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
transform: rotateY(180deg);
/*
* Hidden until the controller boots, and then never touched again.
*
* With no script the card cannot flip, so the back face is never
* wanted — and `visibility: hidden` keeps it out of the tab order in
* every browser, including ones that do not support `inert`. Once the
* controller is running it owns reachability through `inert` and
* `aria-hidden`, and `backface-visibility` owns what is painted, so
* visibility has no further job and is simply released.
*
* This used to be a delayed `visibility` transition per face. That
* worked, but it animated a property on the rotating faces and had to
* be carefully armed after first paint to avoid exposing the back face
* for a whole flip duration on load. Handing the job to `inert` removes
* the transition, the arming, and the bug it was guarding against.
*/
visibility: hidden;
}
.blur-image-card[data-bic-ready] .bic-face--back {
visibility: visible;
}
/*
* The colour field.
*
* A decorative <img> rather than a `background-image`, so no caller-supplied
* string is ever interpolated into a CSS `url()`. Oversized past every face
* edge by the blur radius and a bit, because a blur samples outside the box
* it is given and would otherwise fade to transparent at the rim.
*/
.bic-image {
position: absolute;
/*
* Negative z-index, so it paints above the face's own glass and below
* everything else in the face. It is colour *through* the glass rather
* than the face's background — at this opacity the section behind the
* card still comes through both.
*/
z-index: -3;
top: calc(var(--bic-bleed) * -1);
left: calc(var(--bic-bleed) * -1);
width: calc(100% + var(--bic-bleed) * 2);
height: calc(100% + var(--bic-bleed) * 2);
max-width: none;
object-fit: cover;
object-position: center;
opacity: var(--bic-image-opacity);
filter:
blur(var(--bic-image-blur))
saturate(0.9)
brightness(0.52)
contrast(1.05);
transform: scale(1.18);
pointer-events: none;
user-select: none;
}
.bic-tint {
position: absolute;
z-index: -2;
inset: 0;
background: var(--bic-face-tint);
pointer-events: none;
}
/*
* Fallback blurred colour source for cards without an image. Oversized and
* clipped by the face so the Gaussian blur never exposes a hard edge. It is
* identical on both faces and has no transition of its own.
*/
.bic-color-ease {
position: absolute;
z-index: -1;
inset: -18%;
border-radius: inherit;
background:
radial-gradient(
62% 72% at 14% 18%,
rgb(91 52 224 / 0.88),
transparent 70%
),
radial-gradient(
58% 74% at 92% 78%,
rgb(34 84 189 / 0.68),
transparent 72%
),
linear-gradient(
125deg,
rgb(36 19 72 / 0.9),
rgb(10 16 35 / 0.96)
);
filter: blur(30px) saturate(0.9);
transform: scale(1.15);
opacity: 0.72;
pointer-events: none;
transition: none;
}
/*
* The reflection. A single soft spot near the upper-left corner, which is
* where the lit edge already is, so the two read as one light source.
* Above the tint and below the content, and on both faces, so the card
* looks like the same object all the way through the rotation.
*/
.bic-face::after {
content: "";
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
background: var(--bic-reflection);
pointer-events: none;
}
/* ---- front face ---------------------------------------------------- */
/*
* Spans the first two rows of the face, and splits them again, so the
* number sits at the top and the title lands on the face's vertical
* centre — the geometry of the original, with the action prompt now a
* real button in the third row rather than a third grid child.
*/
.bic-content--front {
grid-row: 1 / 3;
display: grid;
grid-template-rows: 1fr auto;
min-height: 0;
}
.bic-number {
align-self: start;
color: rgb(255 255 255 / 0.88);
font: 700 clamp(0.75rem, 4cqi, 0.9rem) / 1 system-ui, sans-serif;
letter-spacing: 0.02em;
}
.bic-title {
align-self: center;
margin: 0;
text-align: center;
text-wrap: balance;
overflow-wrap: anywhere;
text-shadow: 0 1px 10px rgb(10 6 26 / 0.45);
font: 500 clamp(1.4rem, 9cqi, 2.2rem) / 1.08 Georgia, serif;
letter-spacing: -0.04em;
}
.bic-action {
grid-row: 3;
align-self: end;
justify-self: center;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
min-height: var(--bic-control-size);
min-width: var(--bic-control-size);
max-width: 100%;
margin: 0;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: 999px;
background: none;
color: inherit;
font: 700 clamp(0.75rem, 4cqi, 0.9rem) / 1.2 system-ui, sans-serif;
text-align: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bic-action-text {
overflow-wrap: anywhere;
}
/* ---- back face ----------------------------------------------------- */
/*
* Deliberately the same rule set as `.bic-action`, down to the type scale
* and the padding. It used to be a bordered icon circle in the top-right
* corner, which made the way out of the card a different kind of object
* from the way in, and put it as far from the prompt you had just pressed
* as the card allows.
*/
.bic-close {
grid-row: 2;
align-self: end;
justify-self: center;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
min-height: var(--bic-control-size);
min-width: var(--bic-control-size);
max-width: 100%;
margin: 0;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: 999px;
background: none;
color: inherit;
font: 700 clamp(0.75rem, 4cqi, 0.9rem) / 1.2 system-ui, sans-serif;
text-align: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bic-close-text {
overflow-wrap: anywhere;
}
.bic-content--back {
grid-row: 1;
display: grid;
grid-template-rows: auto;
/*
* Centred in the space the return control leaves, which is the whole
* face above it. The old rule centred the copy and then pulled it up by
* a percentage of the card, to hold it clear of a close button in the
* opposite corner; with the control at the foot there is nothing to
* dodge, and the copy sits where the front face's title sits.
*/
align-content: center;
gap: 1.35rem;
min-height: 0;
/* Long copy scrolls inside the face rather than escaping a card whose
height is fixed by an aspect ratio. */
overflow-y: auto;
overscroll-behavior: contain;
}
.bic-description {
max-width: 34ch;
margin: 0;
text-wrap: pretty;
overflow-wrap: anywhere;
text-shadow: 0 1px 8px rgb(10 6 26 / 0.4);
font: 400 clamp(0.9rem, 4.8cqi, 1.1rem) / 1.5 Georgia, serif;
}
.bic-icon {
flex: none;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
/* ---- controls ------------------------------------------------------ */
/*
* Beats the `display` rules above, which would otherwise keep a `hidden`
* button on screen. Both controls are served hidden and revealed by the
* controller, so a page with no script shows no dead affordance.
*/
.bic-action[hidden],
.bic-close[hidden] {
display: none;
}
.bic-action:focus-visible,
.bic-close:focus-visible {
outline: 3px solid var(--bic-ring);
/* Inside the face, so the ring is never clipped by `overflow: hidden`. */
outline-offset: -1px;
border-radius: 999px;
}
/* ---- responsive ---------------------------------------------------- */
/* Tablet and desktop are square. Mobile stays 4:3. */
@media (min-width: 48rem) {
.blur-image-card {
aspect-ratio: var(--bic-wide-ratio);
}
}
/*
* Reduced motion changes the state instantly instead of rotating, and
* overrides whatever `duration` was configured — including the delay that
* keeps the outgoing face visible mid-flip, because there is no mid-flip
* left to cover.
*/
@media (prefers-reduced-motion: reduce) {
.blur-image-card {
--bic-motion-duration: 1ms;
}
.bic-inner {
will-change: auto;
}
}
</style>
===== END src/components/BlurImageCard.astro =====
===== FILE (supporting): src/components/BlurImageCard.md =====
# Blur image card
A small crystal-glass card. The surface is translucent and blurs whatever the
section puts behind it; its own image sits behind that glass as a soft colour
field. The front carries a number, a title and an action prompt; the card turns
over on a horizontal 3D flip to show a description, with a return control at the
foot of the back face where the action prompt was.
It is a card, not a section. It caps at `22rem` on its own and fills its column
inside a grid — see [Sizing](#sizing) and
[A four-card section](#a-four-card-section).
Catalog page: `/components/blur-image-card`
## Purpose
For a row of three or four cards where each one needs a second layer of detail
that does not have to be on screen at once — services, rooms, sessions,
programme entries. The glass keeps the section's own colour visible through
every card, the blurred image gives each one its own warmth without asking the
reader to decode a photograph at card size, and the flip keeps the detail one
gesture away instead of on a separate page.
**It needs something behind it, and something *with detail in it*.** On a white
page a translucent card is indistinguishable from an opaque one. On a flat
colour it is barely better: a blurred flat colour looks exactly like an
unblurred one, so the glass has nothing to prove. Give the section a gradient,
an image, a grid, a shape — anything with an edge the blur can soften. The
catalog preview uses a violet key light upper-left, a cooler blue fill
lower-right, two small soft accents placed clear of the card, and a hairline grid
at 0.028 alpha — enough edge detail to prove the blur without turning the page
into a test pattern. An earlier version used one enormous ring, which read as
diagnostic scaffolding and kept landing tangent to the card's border. All of it
lives in the preview document: none is in the component, which is what keeps the
card portable.
## Files required
**`BlurImageCard.astro`** — one file, the whole component.
- No imports except `import type { ImageMetadata } from "astro"`, which is a
type and disappears at build time.
- No npm packages, no animation library, no React, no Tailwind, no CSS reset.
- No global stylesheet. Every colour, size, duration and easing is declared on
the component's own root element as a `--bic-*` custom property, so it
inherits nothing from the host page and leaks nothing back.
- No webfonts and **no network requests of its own**. The only thing it loads
is the image you pass in `src`.
- The only assumption about the host is that Astro compiles `.astro` files.
`BlurImageCard.md` — this file. Documentation, not required to run.
## Basic usage
```astro
---
import BlurImageCard from "../components/BlurImageCard.astro";
import roomImage from "../assets/fitting-room.jpg";
---
<BlurImageCard
src={roomImage}
number="01"
title="Meet in private"
actionLabel="View details"
description="Private, unhurried fittings in a calm room, with the whole afternoon held for one appointment."
/>
```
That is the whole integration. No client directive, no wrapper, no init call —
the component ships its own module script and boots itself.
Put it on a section with some colour in it, or the glass has nothing to be glass
against:
```astro
<section class="showcase">
<div class="showcase-inner">
<BlurImageCard src={roomImage} title="Meet in private" … />
</div>
</section>
<style>
.showcase {
background:
radial-gradient(circle at 15% 20%, rgb(91 52 224 / 0.45), transparent 35%),
radial-gradient(circle at 85% 75%, rgb(185 167 255 / 0.28), transparent 38%),
#130f40;
padding-block: clamp(3rem, 8vw, 6rem);
padding-inline: clamp(1rem, 5vw, 3rem);
}
.showcase-inner {
max-width: 1280px;
margin-inline: auto;
}
</style>
```
The section is the host's, not the component's. Baking a background into the
card would make it unusable in anybody else's section, and it is the one thing
a card must never own.
## Sizing
The card caps itself:
```css
width: min(100%, var(--bic-max-width)); /* --bic-max-width: 22rem */
margin-inline: auto;
```
One rule covering both cases. Standing alone it stops at 352px and centres.
Inside a column narrower than 352px it simply fills the column — and still
centres, including inside a grid cell, so a row of cards in wide columns stays
visually even.
Override it per card, or for a whole row:
```css
.feature-card { --bic-max-width: 28rem; }
.compact-row .blur-image-card { --bic-max-width: 16rem; }
```
Measured: 288px in a one-column grid at 320px, 352px standing alone from 390px
up, 278px in a four-column 1280px section, 302px at 1440px.
## Props
`title` is required; everything else has a default. `src` is optional because
the default material is the backdrop, not an image.
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `src` | `string \| ImageMetadata`? | — | Optional image, blurred behind the glass on both faces. **Contributes nothing at the default `--bic-image-opacity: 0`** — omit it entirely and no `<img>` is rendered or fetched. An unsafe value renders no image at all. |
| `title` | `string` | — | The large serif heading on the front face. |
| `number` | `string?` | `"01"` | The small eyebrow at the top of the front face. |
| `actionLabel` | `string?` | `"View details"` | The visible label of the opening button. |
| `description` | `string?` | `"Add the supporting details for this card."` | The copy on the back face. |
| `returnLabel` | `string?` | `"Return"` | The visible label of the returning button at the foot of the back face. |
| `ariaLabel` | `string?` | the `title` | Accessible name for the card as a whole. |
| `openLabel` | `string?` | `` `${actionLabel}: ${title}` `` | Accessible name for the opening button, so several cards on one page are told apart. |
| `closeLabel` | `string?` | `` `${returnLabel}: ${title}` `` | Accessible name for the returning button. The default keeps the visible word inside the accessible name, which is what voice control acts on. |
| `blur` | `number?` | `30` | Blur radius of the **image** behind the glass, in pixels. Clamped to `0`–`120`; a negative, `NaN` or `Infinity` value falls back to `30`. The glass's own blur is `--bic-glass-blur`, because it belongs to the surface rather than to the picture. |
| `duration` | `number?` | `700` | Flip duration in milliseconds. Clamped to `0`–`5000`; a bad value falls back to `700`. Reduced motion overrides it entirely. |
| `class` | `string?` | — | Extra class on the `<article>`, for project-specific layout. |
| `id` | `string?` | — | Element id. Also seeds the internal ids instead of a random value. |
The public type is exported:
```ts
import type { BlurImageCardProps } from "../components/BlurImageCard.astro";
```
## Named slots
Two slots, `front` and `back`, each replacing the *content* of its face. The
open and return buttons are always rendered by the component and are never
inside a slot — that is what guarantees a card always has real controls,
whatever you put in it.
```astro
<BlurImageCard src={image} title="Private fittings">
<div slot="front">
<span class="eyebrow">Since 1994</span>
<h3>Private fittings</h3>
</div>
<div slot="back">
<p>Any back-face markup, including your own links.</p>
<a href="/book/">Book a slot</a>
</div>
</BlurImageCard>
```
Slotted content is protected exactly like the built-in content: `aria-hidden`,
`inert` is applied to the **face**, not to the elements the component happens to
know about, so a link you drop into the `back` slot is unreachable while the
front is showing. There is no list of element types to
keep in step.
Note that the slotted markup carries your page's style scope, not the
component's, so `--bic-*` custom properties inherit into it but the component's
own class rules do not apply to it.
## Local images
Both forms work.
```astro
---
// 1. imported asset — carries its intrinsic size, so the browser reserves the box
import field from "../assets/field.jpg";
---
<BlurImageCard src={field} title="Imported asset" />
<!-- 2. a path under public/ -->
<BlurImageCard src="/blur-image-card/amber-field.svg" title="Public path" />
```
The image is rendered as a decorative `<img>` on each face — empty `alt`,
`aria-hidden="true"` — rather than a CSS `background-image`. That is deliberate:
a caller-supplied string never reaches a CSS `url()`, so there is nothing to
escape and nothing to break out of.
Because the image is blurred to a colour field, it never needs to be large or
detailed. The catalog's own preview uses two hand-drawn 1.6 KB SVG gradients.
Both faces reference the same URL, so the browser fetches it once.
**Use a local asset.** The component makes no requests of its own, and passing a
third-party URL would make the page depend on somebody else's server.
## Responsive ratios
| Viewport | Ratio |
| :--- | :--- |
| Below `48rem` (768px) | `4 / 3` |
| `48rem` and above | `1 / 1` |
Both come from custom properties, so a card can be retuned without touching the
file:
```css
.wide-card {
--bic-mobile-ratio: 16 / 9;
--bic-wide-ratio: 4 / 5;
}
```
The ratio switches on the **viewport**, while the typography switches on the
**container**. That is deliberate: the ratio is a layout decision the page makes
once, and the type has to suit the card it is actually in, which in a four-column
grid is much narrower than the viewport implies.
Typography is driven by container queries (`cqi`), not the viewport, so a card
in a narrow sidebar sets type at sidebar scale rather than page scale. The card
itself is the query container.
Verified with no horizontal overflow and no face escaping the card at 320, 390,
640, 768, 834, 1024, 1280 and 1440 CSS pixels. Both controls stay at least
44×44 at every one of them.
## Interaction
One rule underneath all of it: **JavaScript is the only source of truth.** There
is no `:hover` rule that turns the card over. Pointer entry is an input handled
in script, exactly like a click or a key, so the visual state, `aria-expanded`,
`aria-hidden` and `inert` cannot disagree.
| Input | What happens |
| :--- | :--- |
| Mouse enters the card (fine pointer) | Opens, transiently, after 150ms of intent. |
| Mouse leaves the card | Closes after 100ms — unless it was pinned, or someone is reading it with the keyboard. |
| Click on the card while it is transiently open | Pins it, so it survives the pointer leaving. |
| Click or tap on the action prompt | Opens and pins, and moves focus to the return control. |
| Click or tap on the return control | Closes, unpins, returns focus to the action prompt, and suppresses hover until the pointer leaves. |
| <kbd>Enter</kbd> / <kbd>Space</kbd> on the action prompt | Same as a click. |
| <kbd>Escape</kbd> while focus is inside the card | Same as the return control, including the hover suppression. |
| Click on a link inside the `back` slot | Navigates. It never toggles the card. |
### The state model
Four flags, and the fourth is the one that is easy to leave out:
| Flag | Meaning |
| :--- | :--- |
| `open` | what the card is showing |
| `pinned` | the visitor asked for it, so a pointer leaving does not undo it |
| `pointerInside` | a fine pointer is within the card's box |
| `hoverSuppressed` | a deliberate close happened with the pointer still inside |
Close a card with the mouse still over it and, without suppression, hover
re-opens it immediately: the return control appears not to work, and a small
movement flips the card again. `hoverSuppressed` is set by that control and
by <kbd>Escape</kbd> whenever the pointer is inside, and is cleared by
`pointerleave` and nothing else. Closed therefore means closed until the visitor
genuinely leaves the card and comes back.
Hover is also intent-gated — **150ms to open, 100ms to close**, each cancelling
the other — so crossing the card on the way somewhere else does not flip it.
Measured: eight in-and-out crossings at 40ms apart produce **zero** state
changes, and one deliberate hover produces **exactly one**.
### Material and movement start together
A reasonable worry about a hover-intent delay is that something changes during
it — a `:hover` rule, a pseudo-element, a custom property — so the card lights up
before it moves. It does not, and that is measured rather than asserted.
There is **no `:hover` rule anywhere in this component**, in the source or in the
built stylesheet. `:hover` participates only in the controller's intent logic and
never styles the glass.
Two independent measurements, both on a region that includes the 60px drop
shadow so nothing is cropped out of the comparison:
**Every material property, sampled every animation frame.** Across 217 frames
covering the delay and the flip, none of `background-color`, `background-image`,
`opacity`, `filter`, `backdrop-filter`, `-webkit-backdrop-filter`, `box-shadow`,
any of the four border colours, `mix-blend-mode`, `clip-path`, `overflow`,
`backface-visibility` or `visibility` changed on either face, on either
`::after`, on either image layer or either tint layer — nor did any `--bic-*`
custom property. **Zero changes.** The first frame on which `transform` differs
is the same frame on which `is-open` is applied.
**Pixels.** Over a region including the 60px drop shadow, 0 pixels differ from
rest at every sample inside both intent delays — opening at 55ms, 98ms and
147ms, closing at 6ms, 57ms and 100ms — and the first frame with any pixel
change is the first frame with a non-zero rotation, in both directions.
**And the frame timeline, measured on the shipped build** with the whole
sequence on one clock, relative to pointer entry:
| event | time |
| :--- | :--- |
| pointer entry | 0ms |
| every frame through the intent delay | angle 0, geometry delta 0 |
| open state applied | **158ms** |
| first non-zero transform | **163ms** (0.28°) |
| first ≥1px of edge/perspective movement | **187ms** (1.66°) |
Nothing at all happens for the first 158ms, and once it does, the surface
response and the movement are 24ms apart rather than the ~200ms they used to be.
That closing of the gap is what [`y1`](#why-y1-is-not-zero) buys.
### Reversing mid-flip
Interrupt a rotation and it returns from where it actually is. That is the
browser's own reversing shortening factor, not something this component does: a
card caught 24° in comes back in about 102ms rather than replaying the full
850ms, on a smooth eased profile.
An earlier version set an inline `transition-duration` to do that scaling by
hand and was measurably worse — changing the duration on a running transition
discards the shortening factor, and the same reversal collapsed in 24ms, a snap.
The fix was to delete the code. If you fork this, do not add it back.
Two details worth knowing, because both were found in a browser rather than
reasoned about:
**Clicking the card pins it.** On a fine-pointer device the pointer reaches the
card before the click does, so the card is already turning and the action prompt
is no longer under the cursor when the button goes down. Without a card-level
pin, `pinned` would be reachable by touch and keyboard only, and a mouse user
could never keep a card open after moving away. The card-level listener can only
ever *pin an already-open card* — it cannot open, close or toggle anything, so
the `<article>` stays a container and gains no role, no tab stop and no keyboard
surface.
**Hover-close ignores mouse focus.** Closing a card returns focus to the action
prompt, because the control that had focus is about to become `inert`. After a
mouse user closes a card, focus is therefore sitting inside it. Treating that as
"someone is reading this" would wedge the card open for the rest of the session,
so the guard tests `:focus-visible` rather than plain focus. Keyboard focus keeps
the card open; mouse focus does not.
**Touch never depends on hover.** `pointerenter` fires for a touch tap too, so
the pointer type is checked as well as the media query. A tap goes through the
button, not through the hover path.
## Accessibility
- The card is an `<article>` with an accessible name. It is **not** focusable
and has no `role="button"`: the two real `<button>` elements are the controls.
- The action prompt is the opening button. It carries `aria-expanded` and an
`aria-controls` pointing at the back face.
- The back face has its own return control at the foot, in the same place as
the action prompt it came from: visibly labelled `returnLabel`, named
`closeLabel` to assistive technology.
- Only the visible face is exposed. The hidden face carries
`aria-hidden="true"`, `inert`, and — once the flip settles —
`visibility: hidden`.
- Both controls are at least 44×44 at every tested width, and focus rings are
drawn inside the face so `overflow: hidden` cannot clip them.
- Text is white with a soft shadow over a tinted, blurred surface. The card
therefore assumes a mid-to-dark section; on a pale one, retune `--bic-ink` and
the glass together — see
[Putting it on a light section](#putting-it-on-a-light-section).
Verified in a browser: with a card closed, the only reachable control is its
action prompt; with it open, the only reachable control is its return control.
`Tab` cannot enter the hidden face, and neither can a
programmatic `focus()`.
### Why three mechanisms for one hidden face
`aria-hidden` handles the accessibility tree. `inert` handles focus and hit
testing. Those two are managed by the controller and are the whole story on a
page where script runs.
`visibility: hidden` covers the third case: **no script at all**. The back face
is served hidden, which keeps its controls out of the tab order in every browser
including ones without `inert` — and without script the card can never flip, so
the back face is never wanted. The moment the controller boots it sets
`data-bic-ready`, the back face is released to `visible`, and from then on
`inert` owns reachability and `backface-visibility` owns what is painted.
This used to be a delayed `visibility` transition on each face. It worked, but
it animated a property on the rotating faces and had to be carefully armed after
first paint to avoid exposing the back face for a whole flip duration on load.
Handing the job to `inert` removed the transition, the arming, and the bug it
was guarding against.
## Safe image sources
`src` is treated as untrusted, because it usually comes from a CMS or a content
collection and becomes an attribute on every render.
Rejected, and rendered as no `<img>` at all:
```text
javascript:alert(1) JaVaScRiPt:alert(1) " javascript:…" (leading space)
java<TAB>script:alert(1) java<NUL>script:alert(1) java<LF>script:alert(1)
vbscript:msgbox(1)
```
Control characters are stripped before the scheme is examined, so an obfuscated
scheme is not a way through.
`data:` is permitted, because an inline SVG is a legitimate image.
A link you put in the `back` slot is your own markup and is not validated here.
The component renders no link of its own.
`javascript:` and `vbscript:` are refused, and a refused `src` renders no `<img>`
at all.
Nothing in this component uses `set:html`.
## The flip
Only one element animates, and only one property. Nothing on a face transitions
— not its background, filter, tint, opacity, border, reflection or shadow —
which was verified by reading computed style: `transition-property` is
`transform` on `.bic-inner` and `none` on both faces.
```css
.bic-inner {
transition: transform var(--bic-duration) cubic-bezier(0.4, 0.08, 0.6, 0.92);
}
```
A face that animated its own material would change substance mid-rotation,
which is the class of defect described above.
The curve is symmetric by construction — `(x2, y2)` is `(1 - x1, 1 - y1)`, so it
is its own 180° rotation about (0.5, 0.5) and opening is closing in reverse.
Sampled every frame through a real 850ms flip, the card passes 90° at **427ms**
opening and **428ms** closing, and the per-frame step rises smoothly to a peak at
the midpoint and falls away again: no pause at 90°, no second acceleration.
### Why `y1` is not zero
The obvious ease-in-out for a flip is `cubic-bezier(0.45, 0, 0.55, 1)`, which
starts at zero velocity. It produces a real perceptual defect here, and the
reason is worth writing down because it is not obvious from the curve.
Chromium re-samples a `backdrop-filter` layer as soon as the rotation is
non-zero, so the **surface responds on the very first transformed frame**. With a
zero-velocity start that frame is only ~0.2°, which moves the card's edges by
about 0.19px — invisible. The result is a card whose surface visibly changes
while the card still looks stationary, and it reads as a colour flash *before*
the flip. Nothing in the material changed; the movement was simply too small to
see yet.
The fix is a small non-zero initial velocity, so the first frames that change
colour are also the first frames that visibly move. Measured on 60Hz display
frames from the frame the open state is applied, averaged over three runs:
| curve | frame 1 | frame 2 | first ≥1px |
| :--- | :--- | :--- | :--- |
| `(0.45, 0, 0.55, 1)` | 0.21° / 0.19px | 0.65° / 0.58px | frame 3 |
| `(0.42, 0.05, 0.58, 0.95)` | 0.55° / 0.50px | 1.37° / 1.24px | frame 2 |
| **`(0.4, 0.08, 0.6, 0.92)`** | **0.91° / 0.62px** | **2.09° / 1.42px** | **frame 2** |
| `(0.38, 0.10, 0.62, 0.90)` | 1.20° / 1.09px | 2.55° / 2.32px | frame 1 |
The geometry column is what matters: a `rotateY` moves the edges as
`1 − cos θ`, so the displacement is *quadratic* in the angle and a rotation has
to reach roughly a degree before it moves anything by a whole pixel. The
adopted curve is the gentlest one that reaches a visible pixel on the frame
after the surface first responds. The duration is unchanged at 850ms — the
perception gap was closed with the curve, not by shortening the flip.
### Firefox does not cull an untransformed face
**`.bic-face--front` carries `transform: rotateY(0deg)`, and removing it breaks
the card in Firefox.** It is an identity transform and it is load-bearing.
Firefox only backface-culls an element that carries a `transform` declaration of
its own. A child that merely inherits its parent's 3D orientation through
`transform-style: preserve-3d` is treated as an ordinary flat layer, and
`backface-visibility: hidden` on it does nothing. The back face was never
affected — it already declares `transform: rotateY(180deg)`. The front face
declared nothing, so an open card painted its reversed front straight through
the back: mirrored label, mirrored title, mirrored control, over the top of the
back-face copy.
**It is only visible because the glass is transparent.** With the card's own
translucent `--bic-glass`, an unculled front reads straight through the back
face. Give the face an opaque background and the identical failure disappears
from view, which is why this can sit in a flip card for a long time without
anyone noticing.
It is the *declaration* that matters, not the matrix. Firefox computes
`rotateY(0deg)` to `matrix(1, 0, 0, 1, 0, 0)` — the same identity it reports for
a face with no transform at all — and culls. There is no equivalent value to
substitute; there is only the presence or absence of the property. A minifier
that shortens it to `rotateY(0)` is fine, and that shortened form was checked in
a built stylesheet rather than assumed.
Everything else was eliminated one property at a time in a live page, and all of
it was innocent: `backdrop-filter`, `clip-path`, `overflow: clip` and
`isolation: isolate` on the face, `will-change: transform` on `.bic-inner`,
`container-type` on the card, and the negative-`z` tint and reflection.
Switching each off left the bleed exactly as it was. A twelve-case minimal
reproduction — two faces, a translucent background and nothing else — fails in
Firefox in its bare form and passes the moment the front face is given any
transform at all, which is the whole rule with the component taken out of it.
Chromium does not need it and is unchanged by it: the face is already a stacking
context through `isolation: isolate`, and already a containing block for its
absolutely positioned children, so a transform adds neither.
Measured in Firefox 156 and Chromium, on a built page.
### What was tried and did not help
Pre-promoting the compositing layers. `will-change: transform` on the faces,
`will-change: backdrop-filter`, and `translateZ(0)` on each face were each
tested alone against pinned angles. All three produced **byte-identical** output
to the baseline at 0.34°, 1° and 2° (`max=94, mean=0.585, 4263 changed pixels`
in every case), and byte-identical resting frames. The layers are already
composited — `.bic-inner` carries `will-change: transform` and the faces sit in
a `preserve-3d` subtree with `backface-visibility` and a backdrop filter — so
there is no new layer to create when the rotation starts and nothing to
pre-promote. None of it was adopted.
## Reduced motion
Under `prefers-reduced-motion: reduce` the flip duration drops to `1ms`,
overriding whatever `duration` was passed. The card still changes state — the
back face still appears — it simply does not rotate.
This is why there are two duration properties. `--bic-duration` is the public
token and is written inline from the `duration` prop; an inline declaration
cannot be overridden from a stylesheet, so a reduced-motion rule that reassigned
it would silently do nothing on every card that passed the prop. The motion
reads `--bic-motion-duration`, which derives from it and *is* overridable.
## The crystal glass
The surface is built from two blurs, and it needs both. `backdrop-filter` blurs
whatever the section puts behind the card; `filter` blurs the card's own image.
The gradient alone is a tinted pane; the backdrop filter alone is a smear with
no substance to it.
Painting order inside a face, back to front:
1. the blurred, saturated backdrop — the section, seen through the card;
2. the translucent white gradient, `--bic-glass`;
3. the card's own image at `--bic-image-opacity` (0.28), blurred and saturated;
4. `--bic-face-tint`, a wash to steady the text contrast — the same on both faces;
5. the reflection, a `::after` pseudo-element;
6. the content.
Layers 3, 4 and 5 are children of the face, and a filtered backdrop is by
definition everything painted *behind* an element — so the card's own image is
never fed back into its own blur, and the section still shows through all of it.
### How transparent it actually is
Measured rather than asserted: the card was rendered over a flat `#101010` and
then a flat `#e0e0e0`, and the same patch of card surface sampled on both.
| Sample point | Dark ground | Light ground | Transmittance |
| :--- | :--- | :--- | :--- |
| Section, beside the card | `16,16,16` | `224,224,224` | — |
| Through the card, lower right | `28,28,28` | `226,226,226` | **95.2%** |
| Through the card, centre | `33,33,33` | `226,226,226` | **92.8%** |
| Through the card, upper left | `45,45,45` | `228,228,228` | **88.0%** |
| Opaque control | `166,103,65` | `166,103,65` | 0% |
The upper-left reads lower only because the specular sits there.
The more important number is the one that is easy to miss: on a **neutral** grey
ground every sample comes back **exactly neutral** — `33,33,33`, `226,226,226`,
R = G = B. With `--bic-image-opacity: 0` the card contributes no colour of its
own at all, so every trace of violet or blue you see in it is the section
showing through. That is the difference between glass and smoked acrylic, and it
is measurable rather than a matter of taste.
### Why there is no midpoint edge cue
Beyond the rejected `translateZ` below, a purely decorative low-opacity edge
highlight near the midpoint was considered and not built. CSS has no hook for
"the element is currently near 90°": an overlay tied to the open state would be
on for the whole flip, which is not a midpoint cue, and anything sharper needs a
script sampling the rotation every frame. That would also break the rule that
only `.bic-inner` transitions, for a cue lasting under a tenth of a second. A
clean thin midpoint is better than a fake glowing edge, so there is no cue.
### Why there is no edge thickness
A small `translateZ` on each face was tried, to give the card a physical edge at
the midpoint. It was measured and rejected:
- it made no visible difference at 85°, 89°, 91° or 95°, because
`backface-visibility: hidden` means only one face is ever painted, so pushing
the two apart along the normal moves a sliver nobody can see;
- it grew the resting face from 352×264 to **352.73×264.55**, pushing it past
the card's own box.
No visible gain, a measurable regression. Anything more elaborate — real side
surfaces — would add geometry to every card to decorate a moment that lasts
under a tenth of a second, so it was not pursued either.
### Edge lighting
Real glass is not lit equally on four sides, so the edge treatment is
directional. Two offset inset shadows do it, which is why there are two lines
rather than four:
```css
box-shadow:
inset 1px 1px 0 rgb(255 255 255 / 0.22), /* top and left catch the light */
inset -1px -1px 0 rgb(8 5 24 / 0.14), /* right and bottom fall away */
0 24px 60px rgb(5 3 20 / 0.2); /* separation from the section */
```
The light is up and to the left, and the specular in `--bic-reflection` is
anchored to the same corner, so the two read as one source. Both are inside the
rounded clip and identical on both faces.
### One material, two faces
The front and back are the same pane of glass seen from either side. Every
material property is identical — background, backdrop filter, saturation,
border, shadow, reflection, tint, radius and clip — and only the content
differs.
That is not decoration; it is a bug fix. The two faces used to carry different
washes, white on the front and dark purple on the back, and swapping material at
the 90° handoff is exactly what people saw as the card "changing colour" mid
flip. Measured over a flat ground at four fixed screen positions, the back
rendered up to **18 levels darker** than the front. Equalising the tint took
that difference to **zero**:
| Position | Before (back − front) | After |
| :--- | :--- | :--- |
| Upper left | `−18 −18 −17` | `0 0 0` |
| Upper right | `−16 −16 −14` | `0 0 0`\* |
| Lower left | `−13 −14 −12` | `0 0 0` |
| Lower right | `−9 −9 −9` | `0 0 0` |
\* measured when the way out of the card was a close button in the upper-right
corner, which that sample could clip — content rather than material. The
control now sits at the foot of the face.
If you retune the glass, retune `--bic-face-tint` and leave it at that. There
is deliberately no per-face override.
### The rounded clip needs `clip-path`, not just `overflow`
The face carries **both** `overflow: clip` and
`clip-path: inset(0 round var(--bic-radius))`, and the second one is a bug fix.
A rounded `overflow` clip is applied to the face's own painting, but the
backdrop-filtered layer is composited separately — and once the face is rotated
in 3D, Chromium clips that layer to the border-box *rectangle* instead of to its
rounded corners. The filtered backdrop then paints into the square corner while
the border still traces the curve, which shows as a wedge of extra card sticking
out of one corner through the middle of the flip.
It was isolated by disabling one layer at a time at 60°: the border, the glass
gradient, the image, the tint, the reflection and both shadows all stayed inside
the rounded corner, and only the backdrop-filtered region escaped it. Adding
`clip-path` put it back inside, exactly on the border.
Keep both declarations if you fork this. `overflow` alone leaves the artifact;
`clip-path` alone would work today but says nothing about ordinary overflow.
### The prefixed and unprefixed properties
The component declares `-webkit-backdrop-filter` inline on the face and the
standard `backdrop-filter` inside an `@supports` block. That is not decoration.
Written next to each other, this project's CSS minifier collapses the pair and
keeps only the prefixed form — verified in the built stylesheet, where the same
thing happens to every other component here that uses a backdrop filter.
Chromium and Safari accept the prefixed name; Firefox accepts only the standard
one. The `@supports` block gives the standard property a rule of its own, which
the minifier has nothing to merge it with, so both survive the build.
If the browser supports neither, the card is still a translucent pane on the
section — it loses the blur, not the glass.
## CSS custom properties
Set any of these on the element, from a class, an id or a wrapper rule.
### Size and motion
| Property | Default | What it controls |
| :--- | :--- | :--- |
| `--bic-max-width` | `22rem` | The standalone maximum width. A narrower column wins. |
| `--bic-mobile-ratio` | `4 / 3` | Aspect ratio below `48rem`. |
| `--bic-wide-ratio` | `1 / 1` | Aspect ratio from `48rem` up. |
| `--bic-radius` | `clamp(1.25rem, 8cqi, 2rem)` | Corner radius of both faces. |
| `--bic-pad` | `clamp(1rem, 7cqi, 1.75rem)` | Padding inside each face. |
| `--bic-duration` | `850ms` | Flip duration. Also written inline by the `duration` prop. |
| `--bic-ease` | `cubic-bezier(0.4, 0.08, 0.6, 0.92)` | Easing of the rotation. The non-zero `y1` is deliberate — see [Why `y1` is not zero](#why-y1-is-not-zero). |
| `--bic-perspective` | `75rem` | Depth of the 3D projection. |
### The glass
| Property | Default | What it controls |
| :--- | :--- | :--- |
| `--bic-glass-blur` | `16px` | How hard the backdrop is blurred. |
| `--bic-glass-saturation` | `115%` | How much the backdrop's colour is lifted. |
| `--bic-glass-opacity` | `0.09` | One dial for how milky the surface is. Both stops of `--bic-glass` derive from it — 1.43× at the lit corner and 0.36× at the far one. Measured centre transmittance: `0.045` → 96.6%, `0.07` → 94.7%, `0.09` → 92.8%. |
| `--bic-glass` | a 145° white gradient | The translucent surface itself, if you want to replace it outright. |
| `--bic-border-opacity` | `0.22` | Alpha of the hairline edge. The inset shadows then light it directionally — see [Edge lighting](#edge-lighting). |
| `--bic-border` | derived from the alpha | The hairline edge, if you want a different colour entirely. |
| `--bic-shadow` | three layers | Narrow lit top edge, very faint bottom edge, and the drop. |
| `--bic-reflection-opacity` | `0.07` | Alpha of the specular highlight. |
| `--bic-reflection` | a wide ellipse off the top edge | The specular, drawn by `::after`. Wide and shallow rather than a tight circle: a circle near the corner reads as a white blob stuck to the card, an ellipse anchored off the top edge reads as a window reflected in the surface. |
### The image and the ink
| Property | Default | What it controls |
| :--- | :--- | :--- |
| `--bic-blur` | `30px` | Image blur. Written inline by the `blur` prop. |
| `--bic-image-blur` | `var(--bic-blur)` | The value the image actually reads, and the one you can override from CSS. |
| `--bic-image-opacity` | `0` | **Off.** The default material is the real backdrop, not a picture painted inside the card. Raise it to let the image contribute atmosphere; above about `0.15` the card stops being glass and becomes a tinted image panel. |
| `--bic-face-tint-opacity` | `0` | Alpha of the wash that steadies text contrast. **Off by default**: the glass is now clear enough that a wash would only dirty it. Raise it if your section is busy enough to fight the type. |
| `--bic-face-tint` | a white wash at the above alpha | The wash itself. **One value, used by both faces** — see [One material, two faces](#one-material-two-faces). |
| `--bic-ink` | `#fff` | Text and icon colour. |
| `--bic-ring` | `currentColor` | Focus ring colour. |
| `--bic-control-size` | `44px` | Minimum size of both controls. |
Two properties are written inline from props and therefore cannot be overridden
from a stylesheet — an inline declaration always wins. Each has a derived twin
that can: `--bic-duration` feeds `--bic-motion-duration`, and `--bic-blur` feeds
`--bic-image-blur`. Override the twin.
`--bic-ground` no longer exists. It was the opaque fill behind the image, and
the surface it belonged to is gone; `--bic-glass` replaces it.
### Putting it on a light section
The ink is white, so the card assumes a mid-to-dark background. On a pale
section, give it dark ink and a darker glass:
```css
.on-light {
--bic-ink: #17132e;
--bic-glass: linear-gradient(145deg, rgb(20 16 45 / 0.14), rgb(20 16 45 / 0.05));
--bic-border: rgb(20 16 45 / 0.22);
--bic-face-tint: linear-gradient(145deg, rgb(20 16 45 / 0.04), transparent);
--bic-reflection: radial-gradient(circle at 24% 14%, rgb(255 255 255 / 0.45), transparent 34%);
}
```
## A four-card section
The layout this card was sized for. Four across on desktop, two on tablet, one
on mobile, with every card centred in its cell and equal within its row.
```astro
<section class="cards-section">
<div class="cards-inner">
<h2>Why your website matters</h2>
<div class="blur-card-grid">
<BlurImageCard src={a} number="01" title="…" description="…" />
<BlurImageCard src={b} number="02" title="…" description="…" />
<BlurImageCard src={c} number="03" title="…" description="…" />
<BlurImageCard src={d} number="04" title="…" description="…" />
</div>
</div>
</section>
```
```css
.cards-section {
background:
radial-gradient(circle at 15% 20%, rgb(91 52 224 / 0.45), transparent 35%),
radial-gradient(circle at 85% 75%, rgb(185 167 255 / 0.28), transparent 38%),
#130f40;
padding-block: clamp(3rem, 8vw, 6rem);
padding-inline: clamp(1rem, 5vw, 3rem);
}
.cards-inner {
max-width: 1280px;
margin-inline: auto;
}
.blur-card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: clamp(1rem, 2vw, 1.5rem);
}
@media (max-width: 64rem) {
.blur-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 40rem) {
.blur-card-grid {
grid-template-columns: 1fr;
}
}
```
`minmax(0, 1fr)` rather than `1fr`: a `1fr` track has a minimum of `auto`, so a
long unbreakable word in one card can push the whole row wider than the section.
Measured in a browser at the documented breakpoints:
| Viewport | Columns | Card width | Ratio |
| :--- | :--- | :--- | :--- |
| 320 | 1 | 288px | `4 / 3` |
| 390 | 1 | 351px | `4 / 3` |
| 640 | 1 | 352px | `4 / 3` |
| 768 | 2 | 338px | `1 / 1` |
| 834 | 2 | 352px | `1 / 1` |
| 1024 | 2 | 352px | `1 / 1` |
| 1280 | 4 | 278px | `1 / 1` |
| 1440 | 4 | 302px | `1 / 1` |
From 834px up the column is wider than `--bic-max-width`, so the card stops at
352px and centres in its cell rather than stretching. Raise `--bic-max-width` on
the grid if you would rather it filled the column.
The copy in this example is a placeholder. Nothing about a particular site
belongs in the component — every string here is a prop.
## Multiple instances
Several cards on one page are independent by construction: every query is
scoped to the instance root, all state lives in the controller's closure, and
ids are minted per instance from `id` or a random suffix.
Verified with two cards using different images, blur strengths, flip durations
and copy lengths: hovering one never moves the other, opening one never changes
the other's `aria-expanded`, and the page carries no duplicate ids.
## Astro navigation
The component survives a project using `<ClientRouter />`.
Every listener is registered with an `AbortController` signal. On
`astro:before-swap` the controller tears the instance down — aborts the signal,
returns the card to the state it was served in, hides both controls again and
clears `data-bic-ready` — and `astro:page-load` re-runs `boot()`, which is
idempotent, so a re-used element boots exactly once.
Verified against a real `ClientRouter` transition, not a dispatched event: at
`astro:before-swap` every signal-bearing registration was aborted and the ready
flag was gone; after navigating, the destination instance initialised and
responded to one input exactly once; and four consecutive navigations left the
live registration count, the live controller count and the timer count flat.
## Without JavaScript
The back-face reveal is progressive enhancement. State it plainly to whoever
owns the content.
Without script:
- The front face renders normally — image, number, title.
- **Neither control is shown.** Both are served with `hidden` and revealed by
the controller, because a prompt that cannot do anything is worse than no
prompt. The action prompt is therefore absent, not dead.
- The back face is `aria-hidden`, `inert` and `visibility: hidden`, so nothing
on it is in the tab order. (With script, `visibility` is released on boot and
`inert` does the job.)
- No console or runtime errors.
Verified with JavaScript disabled: zero visible controls, zero reachable
elements inside either card, and no horizontal overflow.
If the back-face content must be reachable without script, it cannot live only
on the back of a card: put it on a real page, and link to that page from the
section around the card rather than from inside it.
## Content length
- **Title** — up to about five or six words. It is set in a `clamp()` that
reaches 3rem and is balanced, so a long title wraps to three lines and stays
inside the face, but it stops being a card title and starts being a sentence.
- **Description** — about 40 words is the comfortable maximum on a square card.
Past that the back face's content wrapper scrolls rather than letting the copy
escape the rounded corner, which is correct but not lovely on a 320px-wide
card.
- **Action and return labels** — two or three words. Both wrap with
`overflow-wrap: anywhere` and neither can overflow the face, but a long label
makes a wide pill at the foot of the card.
## Copying it into another Astro project
Copy `BlurImageCard.astro` into `src/components/` and copy whatever image you
pass in `src`. Nothing else travels, and nothing else has to be installed.
## Differences from the version this was built from
The component was rebuilt from a supplied file. The visual treatment is
unchanged — the blurred field, the tint, the type scale, the `ease-in-out`
rotation, the ratios. The behaviour is not:
- The focusable `<article tabindex="0">` acting as a button is gone. There are
two real buttons.
- The CSS `:hover` rule that turned the card over independently of
`aria-expanded` is gone. Hover is handled in script.
- The hidden face is now `aria-hidden` and `inert` instead of merely rotated
away, so nothing on it is tabbable; with no script at all the back face
is served `visibility: hidden`, which covers browsers without `inert`.
- The `url()` built from an interpolated `src` is now a decorative `<img>`.
- `src` is validated; `blur` and `duration` are clamped.
- Listeners carry an `AbortController` signal and are torn down on
`astro:before-swap`.
- Everything is namespaced `--bic-*` / `.bic-*` instead of `--card-*` /
`.flip-card__*`.
It was refined twice more after that: once to make the glass genuinely
transparent and to fix a rounded-clip defect in the rotation, and once to remove
a front/back material mismatch, take the surface down to clear glass, and
replace the flip's easing with a symmetric curve over 850ms.
The surface was refined again afterwards. The opaque brown face became a
translucent crystal-glass one with a real backdrop filter; the image moved from
being the face's background to a 28%-opacity layer behind the glass; the heavy
brown overlay became a restrained wash; the card gained `--bic-max-width` and
stopped growing to fill whatever it was dropped into; and the radius and type
scale came down to suit a 352px card.
## Known limitations
- **No back face without script.** By design, and stated above. A card whose
detail must always be readable wants a different component.
- **The hover path and the pin are one gesture on a mouse.** Moving onto the
card opens it, so a mouse user's click lands on the back face and pins it,
rather than pressing the action prompt. The outcome is the one they wanted;
the button they aimed at is not the element that received the click.
- **It needs a section behind it.** On a white page the glass has nothing to
filter and nothing for its shadow to fall on, and the card reads as a pale
rectangle. This is a property of glass, not a bug, but it is a real
constraint on where the card can be used.
- **White ink does not work on light grounds.** Measured over a pale `#e8e6f2`
ground, the default white ink scores **1.30:1** against the card surface —
a clear failure, not a marginal one. The documented light-section recipe
scores **10.76:1** on the same ground, so the fix works, but it is four custom
properties and it is not automatic. Each pass has made the glass more
transparent, which makes this sharper rather than softer: do not put the
default card on a light section.
- **`backdrop-filter` is doing real work.** Where it is unsupported the card is
still translucent and still readable, but it is a flat pane rather than
glass.
- **The flip is now verified in Chromium and Firefox. Safari is still
untested.** Each engine needed its own fix and neither fix helps the other:
`clip-path` on the face for the Chromium rounded-clip defect, and
`transform: rotateY(0deg)` on the front face for Firefox's backface culling.
Both are in the component and both are harmless in the other engine. Safari
has not been watched — a headless WebKit build was tried and it fails the
bare two-face flip card in every configuration, so it does no backface culling
at all and says nothing about real Safari either way. That needs a Mac or an
iPhone.
- **A transparent face hides nothing.** Both flip defects above were invisible
until the glass got transparent enough to see through, and both would return
unnoticed if either fix were removed on a card with an opaque face. If you
fork this and make the faces opaque, the fixes are still correct; you have
just lost the ability to tell when they stop working.
- **Long back-face copy scrolls.** A fixed aspect ratio and unbounded text
cannot both win. The scroll container is the safe loser, but see
[Content length](#content-length).
- **`Escape` only works while focus is inside the card.** The handler is on the
card root rather than the document, so a card opened by hover with focus
elsewhere is closed by moving the pointer away, not by a key.
- **3D transforms and `backface-visibility` are required.** A browser without
them shows both faces stacked. Every browser Astro targets has had them for a
decade.
===== END src/components/BlurImageCard.md =====
===== FILE (required): public/blur-image-card/amber-field.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200" width="1200" height="1200" role="presentation">
<!--
Original artwork for the BlurImageCard catalog preview.
Drawn for this library: gradients and soft blobs only, no traced or
third-party imagery, no external references, no embedded raster data.
-->
<defs>
<linearGradient id="a-ground" x1="0" y1="0" x2="0.65" y2="1">
<stop offset="0" stop-color="#9c6a44"/>
<stop offset="0.55" stop-color="#7a4f33"/>
<stop offset="1" stop-color="#40291c"/>
</linearGradient>
<radialGradient id="a-sun" cx="0.68" cy="0.22" r="0.55">
<stop offset="0" stop-color="#ffd9a0" stop-opacity="0.95"/>
<stop offset="1" stop-color="#ffd9a0" stop-opacity="0"/>
</radialGradient>
<radialGradient id="a-ember" cx="0.22" cy="0.78" r="0.6">
<stop offset="0" stop-color="#c2643a" stop-opacity="0.8"/>
<stop offset="1" stop-color="#c2643a" stop-opacity="0"/>
</radialGradient>
<filter id="a-soft" x="-25%" y="-25%" width="150%" height="150%">
<feGaussianBlur stdDeviation="90"/>
</filter>
</defs>
<rect width="1200" height="1200" fill="url(#a-ground)"/>
<g filter="url(#a-soft)">
<ellipse cx="300" cy="300" rx="320" ry="260" fill="#e0a266" opacity="0.55"/>
<ellipse cx="930" cy="760" rx="380" ry="300" fill="#5a3421" opacity="0.7"/>
<ellipse cx="620" cy="1060" rx="460" ry="220" fill="#2d1c12" opacity="0.65"/>
<ellipse cx="1080" cy="180" rx="240" ry="200" fill="#ffc98a" opacity="0.5"/>
</g>
<rect width="1200" height="1200" fill="url(#a-sun)"/>
<rect width="1200" height="1200" fill="url(#a-ember)"/>
</svg>
===== END public/blur-image-card/amber-field.svg =====
===== FILE (required): public/blur-image-card/rose-field.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200" width="1200" height="1200" role="presentation">
<!--
Original artwork for the BlurImageCard catalog preview (second instance).
Same construction as amber-field.svg in a rose and plum key, which is the
other half of the demonstration: a different hue under the same tint.
-->
<defs>
<linearGradient id="r-ground" x1="0.1" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="#a45f6b"/>
<stop offset="0.5" stop-color="#743c50"/>
<stop offset="1" stop-color="#2e1a26"/>
</linearGradient>
<radialGradient id="r-glow" cx="0.3" cy="0.24" r="0.6">
<stop offset="0" stop-color="#ffd2cd" stop-opacity="0.75"/>
<stop offset="1" stop-color="#ffd2cd" stop-opacity="0"/>
</radialGradient>
<radialGradient id="r-deep" cx="0.82" cy="0.84" r="0.58">
<stop offset="0" stop-color="#2a1320" stop-opacity="0.85"/>
<stop offset="1" stop-color="#2a1320" stop-opacity="0"/>
</radialGradient>
<filter id="r-soft" x="-25%" y="-25%" width="150%" height="150%">
<feGaussianBlur stdDeviation="95"/>
</filter>
</defs>
<rect width="1200" height="1200" fill="url(#r-ground)"/>
<g filter="url(#r-soft)">
<ellipse cx="880" cy="260" rx="330" ry="270" fill="#d08a86" opacity="0.6"/>
<ellipse cx="240" cy="700" rx="360" ry="320" fill="#8a4257" opacity="0.7"/>
<ellipse cx="700" cy="1080" rx="480" ry="240" fill="#2b1520" opacity="0.7"/>
<ellipse cx="120" cy="140" rx="230" ry="190" fill="#ffc9b4" opacity="0.5"/>
</g>
<rect width="1200" height="1200" fill="url(#r-glow)"/>
<rect width="1200" height="1200" fill="url(#r-deep)"/>
</svg>
===== END public/blur-image-card/rose-field.svg =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
NOT production ready in the library.
Verified 2026-09-16 at 1440, 1280, 1024, 834, 768, 640, 390, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source has CHANGED since verification. Treat every result above as expired.
--- NOT PROVEN ---
- Nothing outstanding in the library's own checks.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.