Component librarylocal catalog
Back to catalog

Blur image card

A small crystal-glass card that blurs the section behind it, with its own image as a soft colour field, turning over on a horizontal 3D flip to show its details on the back.

cardflip3dblurmediainteractive

Open preview in a new tab

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Ships script
Profile
core only
Installation files
3 required + 1 supporting
Verified
2026-09-16
Client navigation
End-to-end passed against a real ClientRouter
Source fingerprint
Source has CHANGED since verification
Production ready
Not yet

Not production ready

Something required failed, is missing, or contradicts a declared fact.

Blocking

  • Component changed after verification — re-run verification and record the new fingerprint.

Verified 2026-09-16 at 1440, 1280, 1024, 834, 768, 640, 390, 320px

  • Full-page contextpass
  • Keyboardpass
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecyclepass
  • Every check below was run against the production build served by `astro preview`, not the dev server. The first lifecycle attempt was run against `astro dev` and had to be discarded: Vite's HMR keeps a live interval, which the probe counts as a timer surviving teardown.
  • Hover, pin and close were driven with a real mouse rather than dispatched events. Pointer entry opens the card; leaving closes it; a click pins it so it survives leaving; the close control closes and unpins; hovering afterwards behaves as unpinned again.
  • Keyboard: Enter and Space both open from the action prompt and move focus to the close button, Escape closes and returns focus to the action prompt, and a pointer leaving does not close a card whose close button holds :focus-visible.
  • Touch: a pointerenter with pointerType 'touch' does not open the card, the tap on the action prompt does, and a touch pointerleave does not close it.
  • Face exposure matches the visual state at every observation: closed, the only reachable control on the page is each card's action prompt; open, only that card's close button and link. Tab cannot enter the hidden face and neither can a programmatic focus().
  • Two defects were found in the browser and fixed before this record was written. (1) A real mouse click could never reach the action prompt, because pointer entry turns the card first — so `pinned` was unreachable on a fine pointer. A card-level click listener now pins an already-open card; it cannot open, close or toggle, so the <article> gains no role and no tab stop. (2) The back face's visibility transition was armed on first paint, so its very first visible -> hidden resolution was delayed by a whole flip duration, leaving the back-face link tabbable for 700ms after every page load in any browser without `inert`. The transition is now armed only once the controller sets data-bic-ready, and the controller forces a reflow first. Re-measured at load and after 1500ms, with and without JavaScript: hidden in every case.
  • Rapid interaction: 16 alternating activations at 30ms intervals settled closed with transform `none`, aria-expanded false and both faces' states consistent. No intermediate transform was stranded.
  • Resize during a flip: with the duration raised to 3000ms, a real viewport resize from 1280 to 390 mid-rotation preserved the open state, finished the rotation, and switched the ratio to 4/3 with no overflow. A further resize to 1440 kept the card open and returned it to 1/1.
  • Ratios measured at all eight widths: 1.333 at 320, 390 and 640; 1.000 at 768, 834, 1024, 1280 and 1440. No horizontal overflow and no face escaping the card at any of them, and both controls stayed at least 44x44. 200% zoom was emulated as a halved 1280x900 CSS viewport (640x450).
  • Long content: instance B carries a 55-word description and a nine-word title. The title wraps and stays inside the face; the description scrolls inside the back face's own content wrapper at 320 and 390 rather than escaping the card. The link then sits below that fold, which is documented rather than called a pass.
  • Reduced motion overrides the configured duration: --bic-duration stays 700ms while --bic-motion-duration becomes 1ms, and the state is fully changed 40ms after activation with no rotation in progress.
  • Link validation, checked by rendering 18 cards on a scratch page: javascript:, JaVaScRiPt:, a leading-space variant, tab-, NUL- and newline-interrupted variants, data:text/html and vbscript: all rendered as inert text with no anchor and no dialog. Absolute paths, fragments, relative paths, mailto:, tel: and https: were all preserved, and linkExternal added target=_blank with rel=noopener noreferrer. A javascript: src rendered no <img> at all. Hostile numbers clamped: blur -40 to 0px, blur Infinity to the 28px default, duration NaN to the 700ms default, duration 999999 to 5000ms. That scratch page was removed afterwards.
  • Declarations corroborated against the rendered fixture: focusable controls present against hasFocusableContent, a transitioned transform against hasMotion, the id prop reaching the component against acceptsIdProp, and two independent instances against multiInstanceSupport.
  • Lifecycle: the ten-step run was scored by the repo's own evaluateLifecycleRun(), unmodified, which returned pass with no failures. At astro:before-swap all signal-bearing registrations were aborted, zero were live, and data-bic-ready was gone; after navigating, the sentinel and navigation entry proved the transition was client-side, both instances re-initialised, and one input produced exactly one response. Four consecutive navigations left live registrations at 2, live controllers at 2 and timers at 0.
  • The probe in src/lib/lifecycle-probe.ts reads its root and ready selectors from a profile driver contract, and a profile-less entry carries none. Those two selectors were supplied in the driver script for this run; nothing in the repo was edited, and the pass/fail rules were used exactly as they are.
  • Zero console errors and zero component-originated external requests in every run. The only requests the fixture makes are its own document, one stylesheet and the two local SVG fields.
  • Re-verified after the crystal-glass revision, which changed the surface, the size and the type scale but no behaviour. Every check above was re-run against the rebuilt production output and the fingerprint recorded here is the revised source. The pointer sequence, the keyboard sequence, Escape and focus restoration, face exposure, the back-face link, reduced motion, the no-JavaScript render and the ten-step lifecycle run all returned the same results as before the revision.
  • Sizing: the card caps at 352px and centres. Measured 305x229 at 320 (4/3), 352x264 at 390 and at the 640x450 zoom emulation (4/3), and 352x352 at 768, 834, 1024, 1280 and 1440 (1/1). It no longer fills the preview at any width, and there is no horizontal overflow anywhere.
  • The documented four-column grid was built and measured: 1 card at 320 and 390, 2 at 768, 834 and 1024, 4 at 1280 (278px each) and 1440 (302px each), equal width and height within every row, cards centred in cells wider than the cap, and no overflow at any width.
  • The glass was proved rather than assumed. Over a hard-edged 45-degree stripe pattern the stripes stay crisp outside the card and resolve to a soft wash inside it, which is the backdrop blur doing work; computed style reads backdrop-filter: blur(24px) saturate(1.45) on the face. Swapping the section from violet to green changes the colour inside the card, so it is genuinely see-through rather than an opaque gradient that happens to match.
  • A defect was found and fixed during this revision: this project's CSS minifier collapses an adjacent `backdrop-filter` / `-webkit-backdrop-filter` pair and keeps only the prefixed form, which was verified in the built stylesheet and affects every other component here that uses one. Firefox accepts only the unprefixed name, so the standard property now lives in an `@supports` block the minifier has nothing to merge it with. Both declarations were confirmed present in the built CSS afterwards.
  • Rotation: captured mid-flip at roughly 75 degrees with the duration slowed to 3000ms. The card stays translucent through the turn with no opaque flash, and the front and back surfaces carry the same glass, border, highlight and reflection.
  • Re-verified again after the transparency pass and the rotation fix. A user screen recording showed a wedge of extra card at the lower-right corner through the middle of every flip. It was reproduced in a diagnostic fixture that pins the card at an exact angle, then isolated by disabling one layer at a time at 60 degrees over a hard-edged stripe pattern: 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 into a square corner. Cause: a rounded `overflow` clip is applied to the face`s own painting, but the backdrop-filtered layer is composited separately and, under a 3D rotation, Chromium clips it to the border-box rectangle rather than to the rounded corners. Fix: `clip-path: inset(0 round var(--bic-radius))` on the face, alongside `overflow: clip`. Re-captured at 60, 75, 85, 90, 95, 105 and 120 degrees: every corner rounded, no wedge, no rectangular layer escaping, no border split or trail, nothing visible at 90 and no opaque flash.
  • Transparency was measured rather than asserted. The card was rendered over a flat #101010 and a flat #e0e0e0 and the same patch sampled on both: through the card 53,44,39 to 217,208,203 against a 16 to 224 ground, which is 78.8 per cent transmittance, up from 59.6 per cent before this pass, against an opaque control that measured 0. Text contrast was measured on the back face over the real section: surface 60,44,96 against white text is 12.2:1.
  • Final values: image opacity 0.14 at blur 30px, glass opacity 0.07 giving a 0.10 to 0.025 gradient, glass blur 20px at 135 per cent saturation, border white at 0.24, top inset highlight 0.20, reflection 0.14 over a 30 per cent radius, front tint 0.03 and back tint 0.10 falling to 0.03.
  • Firefox was not available in this session, so the flip has not been watched there. The unprefixed backdrop-filter declaration was confirmed present in the built stylesheet, which is the part Firefox needs, but neither the Chromium clipping defect nor the clip-path fix is confirmed on Gecko.
  • Re-verified a third time after the clear-glass pass. A second user recording showed the card still reading as a translucent purple panel and its colour changing at the flip. Cause of the colour change, isolated by ablation rather than guessed: the two faces carried DIFFERENT tint materials — white on the front, dark purple on the back — so the card swapped substance, not just content, at the 90 degree handoff. Measured over a flat ground at four fixed screen positions, the back rendered 9 to 18 levels darker than the front; equalising the tint token took that difference to exactly zero at every position. A residual difference at mid-card survives and is correct: it is the front title`s glyphs, which is content. Computed style now reports zero differences between the faces across background, backdrop-filter and its prefixed form, border, box-shadow, radius, clip-path, overflow, backface-visibility, opacity, filter and blend mode, and identical reflection, tint and image layers.
  • Transparency re-measured over the same flat controls: through the clear lower-right of the card, 29,27,25 on a 16 ground and 218,216,214 on a 224 ground, which is 90.9 per cent transmittance, up from 78.8. The upper left reads 80.8 per cent because the specular highlight sits there. Text contrast over the real section: front title backdrop 42,32,87 at 14.56:1 against white, back description backdrop 48,36,95 at 13.61:1.
  • Final glass values: image opacity 0.06 at 30px blur, glass opacity 0.045, glass blur 18px at 120 per cent saturation, one face tint at 0.015 shared by both faces, border alpha 0.22, reflection alpha 0.10, inset highlights 0.16 and 0.04.
  • The flip now transitions only the rotating element`s transform, over 850ms on cubic-bezier(0.45, 0, 0.55, 1). Computed style confirms transition-property `transform` on the inner element and `none` on both faces. Sampled every frame through a real flip: the card passes 90 degrees at 427ms of 850, angle(t) plus angle(850-t) stays within two degrees of 180 across the run, and the per-frame step rises smoothly to a midpoint peak then falls monotonically — no pause at 90, no second acceleration near the end.
  • The per-face delayed `visibility` transition was removed. The back face is served `visibility: hidden` and released once the controller sets data-bic-ready; with no script it stays hidden, which is correct because the card cannot flip without script. This removes the last transition from the faces and the first-paint arming it needed.
  • Tested over four grounds — the catalog`s purple gradient, a hard-edged four-colour 45 degree stripe pattern, a light #e8e6f2 and a near-black #07060d. The material stays visibly transparent on all four and the front and back match on all four. Over the light ground the default white ink measures 1.30:1 against the card surface, which fails; the documented light-section recipe measures 10.76:1 on the same ground. That limitation is recorded in the component`s documentation rather than papered over.
  • Angles re-checked at 60, 75, 85, 89, 90, 91, 95, 105 and 120 over the stripe pattern: all four corners rounded, no wedge, no rectangular escape, no border split, nothing visible at 90 and no opaque flash.
  • Re-verified a fourth time after the crystal-glass pass. A third recording showed a genuine interaction defect: closing the card with the pointer still over it let hover re-open it immediately, so the close button appeared not to work and crossing the boundary produced extra flips. Fixed with an explicit state model — open, pinned, pointerInside, hoverSuppressed — plus hover intent of 150ms to open and 100ms to close, each cancelling the other. Driven with a real mouse end to end: hover opens, exit closes, click pins, pinned survives exit, Close unpins and closes, and the card then stays closed with the pointer inside through further movement and a 1.5 second wait, re-opening only after the pointer has left and come back. Escape behaves identically and restores focus to the action prompt. Eight in-and-out boundary crossings 40ms apart produced zero state changes; one deliberate hover produced exactly one.
  • Mid-flip reversal was measured rather than assumed. A previous pass set an inline transition-duration to scale the reversal by hand; measurement showed that discards Chromium`s reversing shortening factor and collapsed a 24 degree reversal into 24ms, a snap. That code was deleted. With the browser left to it, the same reversal returns from 23.9 degrees to 0 in 102ms on a smooth monotonic eased profile, and the declared 850ms cubic-bezier(0.45, 0, 0.55, 1) is unchanged for a full flip, which still crosses 90 degrees at 428ms.
  • Material: image opacity 0.02, glass opacity 0.035, glass blur 16px at 115 per cent saturation, face tint 0 (off by default), border alpha 0.20, reflection alpha 0.09 as a wide ellipse anchored off the top edge rather than a tight corner circle, and directional edge lighting from two offset inset shadows (light top and left, shade right and bottom). Front and back remain one material: measured parity is zero at every sampled position on both flat grounds, the only non-zero reading being a one-level difference where the sample clips the back face`s close button.
  • Transparency re-measured on flat controls: 96.2 per cent at the clear lower right, 95.2 per cent at the centre, 89.4 per cent at the upper left where the specular sits. Samples are near-neutral on a neutral ground (22,21,21 and 222,221,221), so the card adds almost no colour of its own. Text contrast over the revised preview scene: front title backdrop 35,26,67 at 16.15:1, back description backdrop 39,30,75 at 15.29:1.
  • A physical edge was tried and rejected on evidence: a 5px translateZ split across the two faces made no visible difference at 85, 89, 91 or 95 degrees, because backface-visibility means only one face is ever painted, and it grew the resting face from 352x264 to 352.73x264.55. No visible gain and a measurable regression, so it was not adopted and no side geometry was added.
  • Tested over four grounds: the revised preview scene (violet key light, cooler blue fill, hairline grid and a soft ring behind the card), a hard-edged four-colour stripe pattern, a light #e8e6f2 and a near-black #07060d. The backdrop is visibly softened through the card and sharp outside it on every one, and the front and back match on every one. The light ground still fails with the default white ink at 1.30:1 and is documented as such.
  • The preview environment is staged entirely by the registry`s own background field. Nothing about it is in the component, and no shared preview infrastructure was touched.
  • Re-verified a fifth time after the crystal-glass polish pass. A reported defect — the material changing colour during the 150ms hover-intent delay, ahead of the rotation — was investigated and NOT reproduced. There is no `:hover` rule anywhere in the component, in source or in the built stylesheet. Every material property was sampled once per animation frame across 217 frames covering the delay and the flip: background-color, background-image, opacity, filter, backdrop-filter and its prefixed form, box-shadow, all four border colours, mix-blend-mode, clip-path, overflow, backface-visibility and visibility, on both faces, both ::after pseudo-elements, both image layers and both tint layers, plus twelve --bic-* custom properties. Zero changed. Pixel captures over a region that includes the 60px drop shadow: opening, 0 pixels differed from rest at 55ms, 98ms and 147ms, and the first differing frame was 200ms at 0.344 degrees, the same frame the open state applied; closing, 0 pixels differed at 6ms, 57ms and 100ms, first difference at 155ms at 179.78 degrees. Material and movement begin on the same frame in both directions. What reads as colour leading movement is the easing: cubic-bezier(0.45, 0, 0.55, 1) is flat at the start, so the surface is already re-rasterising as a rotated backdrop-filtered layer while the rotation is still too small to see.
  • Material reworked to neutral crystal glass. Image opacity is now 0 by default, so the card contributes no colour of its own and the section supplies all of it; `src` became optional, and omitting it renders no img and fetches nothing. Measured on a neutral grey control the samples come back exactly neutral — 33,33,33 on dark and 226,226,226 on light, R=G=B. Centre transmittance 92.8 per cent, inside the requested 90 to 93 band, chosen by measurement: glass opacity 0.045 gave 96.6 per cent, 0.07 gave 94.7 per cent, 0.09 gave 92.8 per cent without looking milky. Lower right 95.2 per cent, upper left 88.0 per cent where the specular sits. Front and back parity is zero at every sampled position on both grounds, the only non-zero reading being one level where the sample clips the back face close button.
  • Final material tokens: glass opacity 0.09, glass blur 16px, glass saturation 115 per cent, image opacity 0, face tint 0, border alpha 0.22, reflection alpha 0.07 as a 120 by 55 per cent ellipse anchored above the top edge at 15 per cent minus 10 per cent, and three-layer directional edge lighting giving 0.28 of light on the top, 0.18 on the left and 0.12 of shade on the right and bottom. Text contrast on the intended dark scene: front title backdrop 45,38,78 at 14.01:1, back description backdrop 47,39,80 at 13.75:1.
  • A decorative midpoint edge cue was considered and omitted: CSS has no hook for the current rotation angle, so any overlay would either be visible for the whole flip or require per-frame scripting, and both break the rule that only .bic-inner transitions. The previously rejected 5px translateZ remains rejected.
  • Back face rebalanced: the description and CTA group is centred in the space below the close control then nudged up, landing its midpoint at 46 per cent of the face, with 22px between description and CTA, 23px clear of the close control, a 44x44 close target and unchanged 352x352 card dimensions. Long description and long CTA text were tested: the group scrolls inside its own wrapper, the CTA stays inside the face, the card does not resize and the page does not overflow.
  • Preview scene simplified: the oversized ring is gone, the grid dropped to 0.028 alpha, and two small soft accents sit clear of the card. Staged entirely by the registry background field.
  • Full regression re-run: 16-step interaction sequence including Close with the pointer inside staying closed through further movement and a 1.5 second wait, re-opening only after leaving and re-entering; eight rapid boundary crossings produced zero committed flips and one deliberate hover produced exactly one; ratios 4/3 below 48rem and 1/1 above with no overflow at 320, 390, 768, 1024, 1280 and 1440; transition-property is transform on .bic-inner and none on both faces at every width; reduced motion 850ms to 1ms; no-JavaScript shows zero controls and zero reachable elements; two instances independent; no duplicate ids, no console errors, no external requests; ClientRouter lifecycle scored pass by the repo evaluator.
  • Sixth pass, addressing a PERCEPTUAL defect rather than a computed-style one: the surface responded on the first transformed frame while the rotation was still too small to see, so the card appeared to change colour before it moved. Compositor pre-promotion was tested first and rejected on evidence — will-change: transform on the faces, will-change: backdrop-filter, and translateZ(0) on each face were each tested alone against pinned angles and every one produced byte-identical output to the baseline at 0.34, 1 and 2 degrees (max=94, mean=0.585, 4263 changed pixels in all four configurations) with byte-identical resting frames. The layers are already composited, so there is no promotion to gain and none was adopted.
  • The remedy was the motion curve. Edge displacement under rotateY goes as 1 minus cos theta, so it is quadratic in the angle and a rotation must reach about one degree to move anything by a whole pixel; measured geometry at pinned angles: 0.34 degrees moves the face by 0.3px, 1 degree by 0.9px, 2 degrees by 1.8px. Four curves were measured on 60Hz display frames averaged over three runs: (0.45, 0, 0.55, 1) gave 0.21 degrees / 0.19px on frame 1 and 0.65 degrees / 0.58px on frame 2, first whole pixel on frame 3; (0.42, 0.05, 0.58, 0.95) 0.55/0.50 then 1.37/1.24; (0.40, 0.08, 0.60, 0.92) 0.91/0.62 then 2.09/1.42; (0.38, 0.10, 0.62, 0.90) 1.20/1.09 then 2.55/2.32. cubic-bezier(0.4, 0.08, 0.6, 0.92) was adopted as the gentlest curve reaching a visible pixel on the frame after the surface first responds. Duration unchanged at 850ms.
  • Verified on the shipped build with the whole sequence on one clock, relative to pointer entry: every frame through the intent delay held angle 0 and geometry delta 0; open state applied at 158ms; first non-zero transform at 163ms at 0.28 degrees; first movement of a whole pixel at 187ms at 1.66 degrees. The surface response and visible movement are now 24ms apart rather than roughly 200ms. Motion quality re-measured on this build: opening crosses 90 degrees at 427ms and closing at 428ms, both monotonic with the per-frame step rising to a single midpoint peak and falling away, settling exactly at 180 and 0; a reversal caught at 26.8 degrees returned to 0 in 114ms on a smooth monotonic profile with no inline duration and no snap. Computed style confirms transition-property transform on .bic-inner and none on both faces.
  • Scope of this pass: the only source change was the --bic-ease value. The checks recorded above for keyboard, no-JavaScript, reduced motion, two instances, overflow, declaration corroboration and lifecycle were last executed against the immediately preceding revision, which differs from this one solely by that CSS timing function; they were NOT re-executed afterwards because the browser tooling disconnected partway through this pass. The frame sheet of the first 350ms and the four-ground angle sweep were likewise not re-captured on this revision. A timing function cannot alter geometry, focus order, inertness, lifecycle or clipping, but that is reasoning rather than a fresh measurement and is recorded here as such.

Open the verification fixtureOpen the ClientRouter fixture

Use this component

Three ways to get Blur image card into one of your own projects, all pre-filled with this component's name and library page.

Start here

Component reference

Paste it into a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving the branding and content open.

Read it first
“Blur image card” from my Astro Component Library:
https://astro.baysixmedia.com/components/blur-image-card/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

ChatGPT brief

Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.

Read it first
Help me add this component to one of my Astro websites:

Component: Blur image card
Library page: https://astro.baysixmedia.com/components/blur-image-card

I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]

Place it:
[ENTER LOCATION OR SELECTOR]

Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]

Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.

Placement prompt

Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.

Read it first
Add Blur image card from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/blur-image-card

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

--- 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.

src/components/library/BlurImageCard.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/blur-image-card/amber-field.svg, public/blur-image-card/rose-field.svg. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
  • packagesInstall alongside it: npm i 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;
	}

	/*
	 * 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>