Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.
Interactivity
Static
Profile
core only
Installation files
1 required + 1 supporting
Verified
2026-09-16
Client navigation
Not applicable — ships no script
Source fingerprint
Matches the verified source
Production ready
Yes
Production-ready component
Every required check passed against the code below, and the recorded fingerprint still matches it.
Verified 2026-09-16 at 1440, 1280, 1024, 834, 390, 320px
✓Full-page contextpass
✓Keyboardpass
✓No JavaScriptpass
✓Reduced motionpass
✓Two instancespass
✓No overflowpass
✓Declarations corroboratedpass
—Lifecycle — declared to ship no scriptnot-applicable
Reference measured live in Chrome at 1440, 1280, 1024, 834, 390 and 320 before any code was written. Geometry: 384px cap, a 2px padding ring rather than a CSS border, 8px outer and 7px inner radius, 32px panel padding, ring #1d293d on panel #0f172b over a #020618 page, heading 30px/36px at weight 700 in #f8fafc with a 16px gap, body 16px/24px in #90a1b9.
Reference motion measured by requestAnimationFrame sampling, because getAnimations() returned 0 on it: the shimmer is a framer-motion JS loop, not a WAAPI animation. A scale(1.75) square rotating 360 degrees at 102.86 deg/s, one revolution in 3.500s, linear to within 0.115 degrees of a straight-line fit across 826 frames over five seconds. Gradient linear-gradient(to bottom right, #c6d2ff 0%, transparent 50%, #c6d2ff 100%). The rotation runs continuously even at rest; only its opacity is hover-gated, 0 to 1 over 500ms on cubic-bezier(0.4, 0, 0.2, 1). Hover also scales the card to 1.01, measured 384 to 387.84px.
This implementation matches the measured geometry exactly: computed radius 8px, padding 2px, panel radius 7px (derived as radius minus half the ring width rather than hardcoded), panel padding 32px, ring rgb(29, 41, 61), panel rgb(15, 23, 43). Hover reaches scale 1.01 and 387.84px, the same figure as the reference.
Sweep verified on the shipped build: getAnimations() reports sbc-sweep, 3500ms, linear, infinite, running. Sampled across 694 frames over 4.2s the angle advanced 431.94 degrees at 102.86 deg/s, one revolution in 3.500s, within 0.277 degrees of a linear fit.
Seamless loop proved with pixels rather than reasoning: the 0deg and 360deg renders are byte-identical PNGs, with a same-angle capture used as a determinism control. Frames 2 degrees apart do differ, so the identity is the loop closing rather than the harness failing to move anything.
A real defect was found by pixel testing and fixed. The gradient was first assembled in the frontmatter as a --sbc-shimmer-gradient token containing var(--sbc-angle). A custom property resolves its own var()s on the element that declares it, so the angle resolved against the CARD root, where --sbc-angle is the registered initial 0deg because the property is declared inherits: false and only the shimmer element animates it. The already-substituted literal then inherited down and the border never moved, while getComputedStyle on the shimmer still reported a correctly animating angle and five screenshots 700ms apart came back identical. Fix: emit only the stop list and assemble conic-gradient(from var(--sbc-angle), var(--sbc-shimmer-stops)) in the stylesheet, on the element the keyframes target. Re-tested: background-image now carries from 101.849deg and from 194.739deg on successive samples, and every spaced frame differs.
A portability defect was found the same way. Token defaults sat in a plain .sbc rule, which Astro ships as .sbc[data-astro-cid-...] at specificity 0,2,0; a consumer class setting --sbc-max-width: 720px measured 384px on screen. The token block moved inside :where() for specificity 0, and the same override then measured 720px with 16px panel padding while a default sibling instance stayed at 384px and 32px.
Layout stability: across 628 consecutive frames of a full revolution the card bounding box held exactly one distinct value, 384x192 at 72,96. Document scrollWidth equalled clientWidth at every width.
Widths 1440, 1280, 1024, 834, 390 and 320 plus 200 per cent zoom emulated as a halved 720x450 viewport at deviceScaleFactor 2: no horizontal overflow anywhere, radius, ring and panel padding constant, sweep running at 3.5s in every case. The card measured 384px when capped, 351px at 390 and 288px at 320.
Aspect-ratio robustness, which is where this departs from the reference on purpose: a conic gradient fills its box at every angle, so a deliberately wide 720x120 card keeps an unbroken border with both arcs intact. The reference's rotating scale(1.75) square only covers its box because that card is square.
Keyboard: a real Tab keypress focuses the link card, :focus-visible matches, and the outline computes to 2px solid rgb(198, 210, 255) at 3px offset. The shimmer also brightens to 1, but the outline does not depend on it.
Reduced motion: sampled across 2s under prefers-reduced-motion: reduce the angle held one value, 0deg, getAnimations() returned 0 and animation-name computed to none. The gradient border still paints and all transitions are removed.
No JavaScript: the rendered preview document contains zero script tags. With script execution disabled the card root, heading, paragraph and sweep layer all render.
Two instances: different inline tokens resolve independently, measured at 3.5s and 6s animation duration with different resolved conic gradients on one page. No duplicate ids anywhere on a twelve-card verification document.
Link safety driven in a browser: javascript:, data:text/html and a tab-interrupted javascript: href each rendered as an inert div with no href and their content intact. No dialog fired and neither sentinel global was set. An absolute https link received target=_blank and rel=noopener noreferrer; an internal path received neither.
CSS injection refused: a shimmerColor carrying a semicolon and a url(), and a borderRadius carrying a second url(), were both rejected; the radius fell back to 8px, the gradient fell back to the default, and the page made zero external requests.
Empty content renders nothing: a card with no title, no body and no slot produced a host element with childElementCount 0 and no text.
shimmerSpeed clamping: a requested 5ms resolved to 0.6s, the documented safe minimum.
Declarations corroborated against the rendered fixture: three focusable link cards against hasFocusableContent, a running sbc-sweep animation against hasMotion, supplied ids reaching the root against acceptsIdProp, two independently-configured instances against multiInstanceSupport, and zero script tags against interactivity: static.
Zero console errors and zero external requests on both the preview document and the twelve-card verification document.
Engines exercised. Blink: Chromium via Playwright MCP, and additionally the Chrome DevTools Protocol in the first pass. WebKit: WebKit 26.5, driven through the Playwright build already present on the machine (playwright-core 1.62.1 against the installed webkit-2336); nothing was installed to do it. Gecko is NOT covered - no Firefox build is available in this environment - and no claim is made about it.
Width matrix re-run end to end through Playwright MCP on Chromium, with a screenshot captured at each stop: 1440, 1280, 1024, 834 and the 720x450 zoom emulation all render the card at 384x192; 390 renders 351x192; 320 renders 288x216. Radius 8px, ring 2px and panel padding 32px are constant at every width, the sweep reports 3500ms linear infinite running at every width, and document scrollWidth never exceeds clientWidth.
The 200 per cent zoom stop is a halved 720x450 CSS viewport at devicePixelRatio 1. That reproduces the reflow behaviour 200 per cent zoom produces, which is what the check is for, but it is not a device-pixel-doubled render; Playwright's resize does not change the scale factor.
Animation read through Playwright MCP getAnimations(): name sbc-sweep, duration 3500ms, easing linear, iterations Infinity, playState running, delay 0, direction normal, fill none. Frame-sampled over 611 frames across 3799ms the RENDERED gradient angle advanced 390.79 degrees at 102.86 deg/s, one revolution in 3.500s, within 1.137 degrees of a linear fit.
Travel path corrected for the record: it is not a left-to-right sweep. The highlight travels as a full 360 degree ROTATION about the card centre - two arcs 180 degrees apart migrating around the perimeter - which is what was measured on the reference and what this reproduces. Confirmed visually through Playwright element screenshots at pinned angles: at 0deg the arcs sit top-right and bottom-left, at 90deg they have moved to top-left and bottom-right.
Loop boundary: one 360-to-0 crossing was observed during the sample and the largest single-frame step across the whole run was 3.507 degrees, which is an ordinary frame advance rather than a jump. There is no reset frame - the stop list opens and closes on a transparent stop, so 0deg and 360deg are the same image.
Bounding-box stability re-confirmed through Playwright MCP: across 611 consecutive frames of a full sweep the card box held exactly one distinct value, 384.000x192.000 at 72.000,96.000. The shimmer cannot move the box.
Two instances re-run through Playwright MCP on the permanent fixture route, deliberately desynchronised by setting instance B's animation currentTime to 3000ms so independence is observable rather than inferred. Instance A: 3500ms period, arc rgb(198, 210, 255), angle 32.07 to 114.94 degrees over 806ms, a measured 102.8 deg/s. Instance B: 6000ms period, arc rgb(125, 211, 252), angle 180.38 to 228.72 degrees, a measured 59.98 deg/s. Each ran at its own declared rate, four ids on the page and zero duplicates, zero script tags, no overflow.
WebKit 26.5 result: CSS.registerProperty is supported, getAnimations() reports sbc-sweep 3500ms linear Infinity running, and the rendered background-image carries a live from-angle (sampled at from 322.148571deg), so the gradient genuinely rotates rather than merely reporting an animating custom property. Sampled over 160 frames across 2507ms it advanced 258.27 degrees at 103.02 deg/s, one revolution in 3.494s. Geometry matches Chromium exactly at 384x192, 8px, 2px, 7px, 32px, with no overflow and zero console errors.
WebKit reduced-motion fallback renders correctly rather than breaking: sampled across 1500ms the angle held one value, 0deg, getAnimations() returned 0 and animation-name computed to none, while the gradient border still painted at opacity 0.55 with ring rgb(29, 41, 61) and panel rgb(15, 23, 43) and the card kept its full width. A static border, not a missing one.
Fingerprint re-checked this session with the repository's own fingerprintFiles() over the required local installation file: sha256:1eefc4da36f990e196e806f5acfc48fc0b7efea630c977e9279de8416fffcea9, 64 hex characters, accepted by the repo's own isWellFormedFingerprint(). It recomputes identically to the value stored here, so the record still describes the shipped source. A 35-character rendering of this value that appeared in an earlier summary was a transcription truncation of the middle of the digest; the stored value was never short or wrong.
npm test re-run this session: exit code 0, 141 tests, 141 pass, 0 fail.
Checks left un-run, stated rather than implied: Gecko/Firefox has no build available here and is untested. The WebKit run used the Playwright WebKit build, which is the same engine family as Safari but is not Safari itself on Apple hardware, so real-device Safari and iOS remain unverified. Touch input was not driven on a real touch device; hover and focus were driven with synthetic pointer and keyboard events.
Three ways to get Shimmer border 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
“Shimmer border card” from my Astro Component Library:
https://astro.baysixmedia.com/components/shimmer-border-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: Shimmer border card
Library page: https://astro.baysixmedia.com/components/shimmer-border-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 Shimmer border card from my Astro Component Library to this Astro project.
Library page:
https://astro.baysixmedia.com/components/shimmer-border-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 ---
Production-ready in the library.
Verified 2026-09-16 at 1440, 1280, 1024, 834, 390, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:1eefc4da36f990e196e806f5acfc48fc0b7efea630c977e9279de8416fffcea9 — MATCHES the files below.
--- NOT PROVEN ---
- ClientRouter: not applicable — this component ships no script.
--- 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/ShimmerBorderCard.astro
one fileNo imports, no companion files, no packages. Copy ShimmerBorderCard.astro into any Astro project's components folder and render it.
---
/**
* ShimmerBorderCard
*
* A card whose border carries a highlight that sweeps continuously around the
* perimeter, brightening on hover and on keyboard focus.
*
* The effect is built the way the reference builds it — an opaque panel inset
* inside a slightly larger rounded box, so the only thing left visible of the
* layer underneath is a hairline ring — but the moving part is done
* differently, and deliberately. See "Why a conic angle" below.
*
* Ships no JavaScript. Nothing here needs a controller: the sweep is a CSS
* animation, the hover and focus states are CSS states, and the card renders
* complete from the server. That is why it enrols as `static` in the readiness
* system and owes no router-lifecycle proof.
*/
export interface ShimmerBorderCardProps {
/** Card heading. Rendered as an <h3>. Omit to render no heading. */
title?: string;
/** Supporting copy, one paragraph. Omit to render no paragraph. */
body?: string;
/**
* Turns the whole card into a link. Unsafe schemes are refused and the
* card falls back to a plain <div>, so a bad value cannot produce a live
* link — see `safeHref`.
*/
href?: string;
/** Overrides the default, which is `_blank` for absolute http(s) links. */
target?: "_blank" | "_self";
/** Overrides the default, which is `noopener noreferrer` on `_blank`. */
rel?: string;
/** Animate the sweep. `false` leaves the same border, held still. */
shimmer?: boolean;
/**
* One full revolution, in milliseconds. Measured default: 3500.
*
* Clamped to 600–60000. The floor is not arbitrary: below about half a
* second the two arcs cross the corners fast enough to read as a flicker
* rather than a sweep, which is both uglier and worse for anyone
* photosensitive who has not set a motion preference.
*/
shimmerSpeed?: number;
/** Colour of the sweeping arcs. Measured default: #c6d2ff. */
shimmerColor?: string;
/**
* Multi-colour variant. One arc per entry, spaced evenly around the ring,
* so three colours give three arcs 120 degrees apart. Wins over
* `shimmerColor` when supplied. A single entry behaves like `shimmerColor`.
*/
shimmerColors?: string[];
/** Any CSS length. Measured default: 8px. */
borderRadius?: string;
class?: string;
id?: string;
}
interface Props extends ShimmerBorderCardProps {}
const {
title,
body,
href,
target,
rel,
shimmer = true,
shimmerSpeed = 3500,
shimmerColor,
shimmerColors,
borderRadius,
class: className,
id,
}: Props = Astro.props;
/*
* Link data is treated as untrusted: it typically arrives from a CMS or a
* content collection and becomes an attribute on every render. Only plain
* paths, fragments and http/https/mailto/tel survive; anything else —
* `javascript:` and `data:` above all — is refused, and the card renders as a
* <div> rather than as a link that runs.
*
* This is the same helper the rest of the library uses, kept identical rather
* than "improved" per component, because a link validator that differs subtly
* between components is a validator nobody can audit.
*/
const SAFE_SCHEME = /^(https?:|mailto:|tel:)/i;
/** Control characters are stripped first: "java\tscript:" is a scheme too. */
function stripControls(value: string): string {
return [...value]
.filter((ch) => {
const code = ch.codePointAt(0) ?? 0;
return code > 31 && code !== 127;
})
.join("");
}
function safeHref(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 (
bare.startsWith("/") ||
bare.startsWith("#") ||
bare.startsWith("./") ||
bare.startsWith("../")
) {
return bare;
}
if (SAFE_SCHEME.test(bare)) return bare;
/* A bare relative path has no scheme before its first slash. */
const colon = bare.indexOf(":");
const slash = bare.indexOf("/");
if (colon === -1 || (slash !== -1 && slash < colon)) return bare;
return undefined;
}
/*
* Caller-supplied CSS values are validated too, for a quieter reason than
* links but a real one.
*
* Every one of these lands in an inline `style` attribute. Astro escapes the
* attribute, so no caller can close it and start a new one — but inside the
* attribute a semicolon starts a new *declaration*, and `url(...)` in a
* declaration is an outbound network request. This library promises previews
* that make no external requests, so the semicolon and the URL are both
* refused here rather than trusted to be absent.
*
* The allowlist is deliberately wide on characters and narrow on functions:
* every colour syntax CSS has — hex, rgb(), hsl(), oklch(), lab(), color(),
* named colours, `currentColor`, `var(--x)` — is made of these characters, and
* none of the resource-fetching functions survive.
*/
const CSS_VALUE_ALLOWED = /^[A-Za-z0-9#%.,()\s/+*_-]+$/;
const CSS_VALUE_DENIED = /\b(url|image|image-set|src|element|attr)\s*\(/i;
function safeCssValue(value: unknown, max = 120): string | undefined {
if (typeof value !== "string") return undefined;
const bare = stripControls(value).trim();
if (bare === "" || bare.length > max) return undefined;
if (!CSS_VALUE_ALLOWED.test(bare)) return undefined;
if (CSS_VALUE_DENIED.test(bare)) return undefined;
return bare;
}
const resolvedHref = safeHref(href);
/*
* An href that was supplied and refused is worth being loud about in
* development: silently rendering a <div> looks like the prop was ignored.
*/
if (href !== undefined && resolvedHref === undefined && import.meta.env.DEV) {
console.warn(
`[ShimmerBorderCard] Refused an unsafe or empty href (${JSON.stringify(href)}). The card rendered as a non-link.`,
);
}
const isLink = resolvedHref !== undefined;
/*
* "External" means an absolute http(s) URL. `mailto:` and `tel:` are excluded
* on purpose: handing them to a new browser tab leaves an empty tab behind
* once the mail client takes over.
*/
const isExternal = isLink && /^https?:\/\//i.test(resolvedHref);
const resolvedTarget = target ?? (isExternal ? "_blank" : undefined);
const resolvedRel =
rel ?? (resolvedTarget === "_blank" ? "noopener noreferrer" : undefined);
/*
* Empty renders nothing at all.
*
* A card with no heading, no copy and no slotted content is an orphan: a
* rectangle with a decorative border and nothing to decorate. `Astro.slots.has`
* answers this at build time, so the decision costs nothing at runtime.
*/
const hasTitle = typeof title === "string" && title.trim() !== "";
const hasBody = typeof body === "string" && body.trim() !== "";
const hasSlot = Astro.slots.has("default");
const hasContent = hasTitle || hasBody || hasSlot;
/* ---- the sweep ------------------------------------------------------- */
const MIN_SPEED = 600;
const MAX_SPEED = 60000;
const speed =
Number.isFinite(shimmerSpeed) && shimmerSpeed > 0
? Math.min(MAX_SPEED, Math.max(MIN_SPEED, Math.round(shimmerSpeed)))
: 3500;
const DEFAULT_ARC = "#c6d2ff";
const paletteInput =
Array.isArray(shimmerColors) && shimmerColors.length > 0
? shimmerColors
: [shimmerColor ?? DEFAULT_ARC];
const palette = paletteInput
.map((c) => safeCssValue(c, 64))
.filter((c): c is string => c !== undefined);
const arcs = palette.length > 0 ? palette : [DEFAULT_ARC];
/*
* Build the conic stop list.
*
* One arc per colour, evenly spaced, each fading to transparent at the
* midpoint between itself and its neighbour. A single colour therefore gives
* TWO arcs rather than one — that is the measured reference pattern, where a
* rotating linear gradient puts its two bright ends on opposite sides of the
* ring, and one bright spot chasing round alone looks nothing like it.
*
* Arc centres sit at half-step offsets so the list starts and ends on a
* transparent stop at 0deg and 360deg. That is what makes the loop seamless:
* `from 0deg` and `from 360deg` are the same image, so the animation has no
* reset frame to hide.
*/
const lobes = arcs.length === 1 ? 2 : arcs.length;
const step = 360 / lobes;
const stops: string[] = ["transparent 0deg"];
for (let i = 0; i < lobes; i += 1) {
const colour = arcs[i % arcs.length];
const start = i * step;
stops.push(
`${colour} ${(start + step * 0.25).toFixed(3)}deg`,
/* The hottest point of the arc. Equal to `colour` unless a caller
raises --sbc-shimmer-highlight, which is why it is a token and not
a literal. */
`var(--sbc-shimmer-highlight, ${colour}) ${(start + step * 0.5).toFixed(3)}deg`,
`${colour} ${(start + step * 0.75).toFixed(3)}deg`,
`transparent ${(start + step).toFixed(3)}deg`,
);
}
/*
* The STOPS only. The `conic-gradient(from ...)` wrapper is assembled in the
* stylesheet instead, and that split is load-bearing rather than tidy.
*
* A custom property resolves the `var()`s inside its own value at computed-
* value time, on the element that declares it. This token is declared on the
* card root, so a `var(--sbc-angle)` written in here would resolve against the
* CARD's angle — which is the registered initial 0deg, because `--sbc-angle`
* is declared `inherits: false` and only the shimmer element ever animates it.
* The already-substituted value then inherits down as a literal `from 0deg`
* and the border never moves, while `getComputedStyle` on the shimmer still
* reports a perfectly good animating angle. It looks completely correct from
* every side except the screen.
*
* Keeping the wrapper in the stylesheet puts `var(--sbc-angle)` directly in
* the `background` declaration on `.sbc-shimmer`, so it resolves on the
* element the animation actually targets.
*/
const stopList = stops.join(", ");
const radius = safeCssValue(borderRadius, 64);
/*
* Only the values a caller actually changed are emitted. An inline style that
* restates every default would win against a consumer's own stylesheet rule
* for no reason, which is the opposite of what these tokens are for.
*/
const styleVars = [
`--sbc-shimmer-stops: ${stopList}`,
`--sbc-shimmer-duration: ${speed}ms`,
radius ? `--sbc-radius: ${radius}` : "",
arcs.length === 1 ? `--sbc-shimmer-color: ${arcs[0]}` : "",
]
.filter(Boolean)
.join("; ");
const Tag = isLink ? "a" : "div";
---
{
hasContent && (
<Tag
class:list={["sbc", className]}
id={id}
style={styleVars}
href={isLink ? resolvedHref : undefined}
target={resolvedTarget}
rel={resolvedRel}
data-sbc-root
data-sbc-shimmer={shimmer ? "on" : "off"}
>
{/*
The moving layer.
Decorative and nothing else: no text, no contrast requirement, no
place in the accessibility tree. `aria-hidden` states that, and
`pointer-events: none` in the stylesheet keeps it from swallowing
clicks meant for the card. A <span> rather than a pseudo-element
because a pseudo-element cannot carry `aria-hidden`, and an
unannotated decorative box is exactly the kind of thing a screen
reader is entitled to be confused by.
*/}
<span class="sbc-shimmer" aria-hidden="true" />
<div class="sbc-panel">
{hasTitle && <h3 class="sbc-title">{title}</h3>}
{hasBody && <p class="sbc-body">{body}</p>}
<slot />
</div>
</Tag>
)
}
<style>
/*
* The animated angle.
*
* `@property` is global by definition — a registered custom property is a
* document-level declaration and cannot be scoped to a component — so the
* name carries the component's prefix. This is the one piece of this
* stylesheet that leaves the component's scope, and it leaves it under a
* name nothing else will claim.
*
* Registration is what makes the angle animatable at all: an unregistered
* custom property is an untyped token, and CSS interpolates it as a
* discrete step from start to end. With `syntax: "<angle>"` it interpolates
* as a number, which is the whole mechanism.
*/
@property --sbc-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes sbc-sweep {
to {
--sbc-angle: 360deg;
}
}
/*
* The token defaults live inside `:where()`, and that is a portability fix
* rather than a stylistic choice.
*
* Astro scopes a component's styles by appending a `[data-astro-cid-…]`
* attribute to the selector, so a plain `.sbc { --sbc-max-width: 384px }`
* ships as `.sbc[data-astro-cid-…]` with specificity 0,2,0. A consumer
* doing the obvious thing — `.my-wide-card { --sbc-max-width: 720px }` on
* the class they passed in — has specificity 0,1,0 and silently loses.
* Measured: a 720px override rendered at 384px.
*
* `:where()` forces the whole selector to specificity 0,0,0 while keeping
* the scoping attribute, so every token below can be overridden by any
* ordinary consumer rule, which is the entire point of exposing them.
* Structural declarations stay in the normal `.sbc` rule underneath, where
* the component does want to win.
*/
:where(.sbc) {
/* ---- measured from the reference ------------------------------
* Every default here was read off the live page rather than chosen.
* The reference builds its ring out of padding rather than a border,
* and this keeps that: `border-width` names a ring thickness, and no
* CSS `border` is ever set. A real border would sit outside the
* background and the conic gradient would not reach it.
*/
--sbc-radius: 8px;
--sbc-border-width: 2px;
--sbc-max-width: 384px;
--sbc-shimmer-duration: 3500ms;
/*
* Linear, and measured to be so: fitted across 826 sampled frames over
* five seconds, the reference deviated from a straight line by at most
* 0.115 degrees. Anything eased would make the arcs hesitate at the
* same two points on every revolution, which turns a sweep into a
* pulse.
*/
--sbc-shimmer-easing: linear;
--sbc-shimmer-color: #c6d2ff;
/* Defaults to the arc colour, so the arcs are flat unless a caller
asks for a hotter core. */
--sbc-shimmer-highlight: var(--sbc-shimmer-color);
/*
* The two-arc default, restated here so the stylesheet stands up on
* its own. The component always emits this inline — built from
* `shimmerColor` or `shimmerColors` — and the inline value wins.
*/
--sbc-shimmer-stops: transparent 0deg,
var(--sbc-shimmer-color) 45deg, var(--sbc-shimmer-highlight) 90deg,
var(--sbc-shimmer-color) 135deg, transparent 180deg,
var(--sbc-shimmer-color) 225deg, var(--sbc-shimmer-highlight) 270deg,
var(--sbc-shimmer-color) 315deg, transparent 360deg;
/*
* Visible at rest, and this is a deliberate departure.
*
* The reference holds the shimmer at opacity 0 until hover, which
* means the effect does not exist for touch users, does not exist in a
* screenshot, and cannot be judged in a component gallery. A low
* resting level keeps the border alive without competing with the
* content, and hover still takes it to full. Set this to 0 to
* reproduce the reference's hover-only behaviour exactly.
*/
--sbc-shimmer-rest-opacity: 0.55;
--sbc-shimmer-hover-opacity: 1;
--sbc-ring-bg: #1d293d;
--sbc-card-bg: #0f172b;
--sbc-card-bg-hover: #1d293d;
--sbc-card-padding: 32px;
--sbc-title-size: 30px;
--sbc-title-line: 36px;
--sbc-title-weight: 700;
--sbc-title-gap: 16px;
--sbc-body-size: 16px;
--sbc-body-line: 24px;
--sbc-text-color: #f8fafc;
--sbc-body-color: #90a1b9;
--sbc-hover-duration: 500ms;
--sbc-hover-easing: cubic-bezier(0.4, 0, 0.2, 1);
--sbc-hover-scale: 1.01;
--sbc-focus-ring: #c6d2ff;
}
/* Structure. Normal specificity, because this is the component's own
layout rather than something a caller is invited to retune. */
.sbc {
position: relative;
display: block;
box-sizing: border-box;
width: 100%;
max-width: var(--sbc-max-width);
/* The ring. Padding, not border — see the token block above. */
padding: var(--sbc-border-width);
border-radius: var(--sbc-radius);
background: var(--sbc-ring-bg);
color: var(--sbc-text-color);
text-decoration: none;
/*
* `scale` as its own property rather than inside `transform`.
*
* A consumer positioning this card with `transform: translate(...)`
* would otherwise have their transform silently replaced by the hover
* state. These are separate properties that compose, so both survive.
*/
transition:
scale var(--sbc-hover-duration) var(--sbc-hover-easing),
background-color var(--sbc-hover-duration) var(--sbc-hover-easing);
}
.sbc:hover {
scale: var(--sbc-hover-scale);
}
/*
* The moving layer.
*
* ---- Why a conic angle rather than a rotating element ----
*
* The reference rotates an absolutely positioned square scaled to 1.75 and
* lets the corners sweep past the border. That works there because its
* card is square: a rotating rectangle only covers the box it sits in at
* every angle if it is square and large enough. Give that card a 16:9
* aspect ratio and the arcs tear away from the short edges partway through
* every revolution.
*
* A conic gradient is angular by construction. It fills its box at every
* angle, at any aspect ratio, with no overscan, no scale factor and no
* transform — so this reproduces the reference's appearance while also
* working on the wide, short and content-sized cards a reusable component
* actually gets used for.
*
* It also costs less: there is no 1.75x-oversized layer to composite, and
* nothing here can affect layout, because an absolutely positioned box is
* out of flow and the only property animating is a paint-level angle.
*/
.sbc-shimmer {
position: absolute;
/*
* The containing block is the card's padding box, and with no CSS
* border that is the whole card — so `inset: 0` covers the ring and
* the interior both. The interior half is then covered by the panel,
* which is the same occlusion the reference uses and needs no
* `mask-composite` support to work.
*/
inset: 0;
z-index: 0;
border-radius: inherit;
/*
* Assembled here, not handed down pre-built. `var(--sbc-angle)` has to
* appear in this declaration, on this element, because this is the
* element the keyframes animate — see the note beside `stopList` in
* the frontmatter for what happens when it does not.
*/
background: conic-gradient(
from var(--sbc-angle),
var(--sbc-shimmer-stops)
);
opacity: var(--sbc-shimmer-rest-opacity);
pointer-events: none;
animation: sbc-sweep var(--sbc-shimmer-duration)
var(--sbc-shimmer-easing) infinite;
transition: opacity var(--sbc-hover-duration) var(--sbc-hover-easing);
}
/*
* Held still, not removed. `shimmer={false}` is a request for a quiet
* border, not for no border, so the gradient stays and the angle stops.
*/
.sbc[data-sbc-shimmer="off"] .sbc-shimmer {
animation: none;
}
.sbc:hover .sbc-shimmer,
.sbc:focus-visible .sbc-shimmer {
opacity: var(--sbc-shimmer-hover-opacity);
}
/*
* The panel.
*
* Opaque, and that matters: it is what confines the shimmer to the ring.
* A translucent `--sbc-card-bg` will let the arcs show faintly through the
* interior — documented rather than defended against, because a caller who
* sets a translucent card background has asked for exactly that.
*/
.sbc-panel {
position: relative;
z-index: 1;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--sbc-card-padding);
/*
* Half the ring thickness inside the outer radius, which is what keeps
* the two curves concentric. At the measured 8px/2px this resolves to
* 7px — the reference's hardcoded inner radius, arrived at rather than
* copied. `max()` stops a thick ring from producing a negative radius.
*/
border-radius: max(
0px,
calc(var(--sbc-radius) - var(--sbc-border-width) / 2)
);
background: var(--sbc-card-bg);
transition: background-color var(--sbc-hover-duration)
var(--sbc-hover-easing);
}
.sbc:hover .sbc-panel {
background: var(--sbc-card-bg-hover);
}
/*
* Slotted content centres; the heading and copy do not.
*
* This is the reference's arrangement: an icon sits centred above a
* left-aligned heading and paragraph. `align-items: center` on the column
* gives the first behaviour, and the full-width children opt out of it.
*/
.sbc-title,
.sbc-body {
width: 100%;
margin: 0;
text-align: start;
}
.sbc-title {
font-size: var(--sbc-title-size);
line-height: var(--sbc-title-line);
font-weight: var(--sbc-title-weight);
color: var(--sbc-text-color);
letter-spacing: -0.01em;
text-wrap: balance;
/* Only when something follows it, so a title-only card has no
phantom space under the text. */
margin-bottom: 0;
}
.sbc-title:not(:last-child) {
margin-bottom: var(--sbc-title-gap);
}
.sbc-body {
font-size: var(--sbc-body-size);
line-height: var(--sbc-body-line);
color: var(--sbc-body-color);
text-wrap: pretty;
}
/*
* A focus style that stands on its own.
*
* The brightened shimmer is a nice confirmation but it is decorative, it
* depends on a colour difference, and under a motion preference it barely
* moves. The outline is the actual affordance; the shimmer is the garnish.
*/
.sbc:focus-visible {
outline: 2px solid var(--sbc-focus-ring);
outline-offset: 3px;
}
/*
* Reduced motion.
*
* The reference does not honour this — measured, not assumed: under
* `prefers-reduced-motion: reduce` its border kept turning at the same
* 102.86 degrees per second. Here everything that moves stops, and nothing
* that informs disappears: the gradient border still renders, held at its
* initial angle, and hover and focus still change its brightness. A user
* who asked for less motion gets a static card, not a lesser one.
*/
@media (prefers-reduced-motion: reduce) {
.sbc-shimmer {
animation: none;
}
.sbc,
.sbc-shimmer,
.sbc-panel {
transition: none;
}
.sbc:hover {
scale: 1;
}
}
/*
* Forced colours.
*
* A gradient border is the first thing a high-contrast theme discards, so
* the ring is restated as a real outline rather than left to vanish.
*/
@media (forced-colors: active) {
.sbc {
outline: 1px solid CanvasText;
}
.sbc-shimmer {
display: none;
}
}
</style>