The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.
Read it first
Install kit: "Shimmer border card"
Library page: https://astro.baysixmedia.com/components/shimmer-border-card/
Files: 1 required, 1 supporting
Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/components/ShimmerBorderCard.astro =====
---
/**
* 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>
===== END src/components/ShimmerBorderCard.astro =====
===== FILE (supporting): src/components/ShimmerBorderCard.md =====
# Shimmer border card
A dark card whose hairline border carries bright arcs sweeping continuously
around the perimeter. Hovering it, or focusing it from the keyboard, brightens
the sweep, lifts the card very slightly and lightens the panel behind the text.
Catalog page: `/components/shimmer-border-card`
## Copying it into another Astro project
Copy **`ShimmerBorderCard.astro`**. That is the entire component — one file, no
second step.
| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Client-side JavaScript | none — it ships no `<script>` at all |
| Global stylesheet | none — every value is a `--sbc-*` property on the root |
| Webfonts | none — system stack |
| Images | none |
| Network requests | none |
### Exact steps
1. Copy `ShimmerBorderCard.astro` into your own `src/components/`.
2. Import it and render it:
```astro
---
import ShimmerBorderCard from "../components/ShimmerBorderCard.astro";
---
<ShimmerBorderCard
title="Ship on Fridays"
body="Small releases, reviewed the same day they are written."
href="/changelog"
/>
```
3. There is no step three. It renders complete from the server.
### The one global it registers
The component registers a single CSS `@property`:
```css
@property --sbc-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
```
**Note for anyone installing this into another project.** `--sbc-angle` is a
registered `@property`, so it registers at the **document level** and sits
outside Astro's scoped-style model — Astro scopes selectors, not at-rules, and
no bundler can scope this one. It is necessary because the border gradient is
assembled at render time against a *moving* angle, and an unregistered custom
property is an untyped token that CSS interpolates as a single discrete jump;
without `syntax: "<angle>"` the border would not move at all. The `sbc-` prefix
is deliberate, so the registration cannot collide with custom properties
already living in the destination. It is a **singleton**: however many cards
are on the page, there is exactly one registration, and all of them read it —
that is expected and correct, because each card's animation sets its own value
on its own shimmer element (`inherits: false` keeps them from leaking into one
another). On a browser with no `@property` support the declaration is ignored,
the angle never animates, and the card renders a static gradient border. That
fallback is intentional and is the same result as `shimmer={false}`.
## Props
| Prop | Type | Default | Notes |
| :--- | :--- | :--- | :--- |
| `title` | `string` | — | Rendered as an `<h3>`. Omit to render no heading. |
| `body` | `string` | — | One paragraph. Omit to render no paragraph. |
| `href` | `string` | — | Turns the whole card into an `<a>`. Unsafe values are refused. |
| `target` | `"_blank" \| "_self"` | `_blank` for absolute `http(s)` links | Overrides the default. |
| `rel` | `string` | `noopener noreferrer` when the target is `_blank` | Overrides the default. |
| `shimmer` | `boolean` | `true` | `false` keeps the same border and holds it still. |
| `shimmerSpeed` | `number` | `3500` | One full revolution, in ms. Clamped to 600–60000. |
| `shimmerColor` | `string` | `#c6d2ff` | Colour of the sweeping arcs. |
| `shimmerColors` | `string[]` | — | One arc per entry, evenly spaced. Wins over `shimmerColor`. |
| `borderRadius` | `string` | `8px` | Any CSS length. |
| `class` | `string` | — | Your own class, for layout or token overrides. |
| `id` | `string` | — | Set on the card root. |
Anything not supplied simply is not rendered. A card with no `title`, no `body`
and no slotted content renders **nothing at all** rather than an empty
decorated rectangle.
### The slot
`<slot />` sits below the title and paragraph, inside the panel. Slotted
children are centred; the title and paragraph are not, which is the reference's
arrangement — an icon centred above left-aligned text.
```astro
<ShimmerBorderCard title="Go faster">
<svg width="48" height="48" aria-hidden="true"><!-- … --></svg>
</ShimmerBorderCard>
```
Nothing in the slot can break the border: the sweep is on a separate,
absolutely positioned layer that is out of flow.
### Why `shimmerColor` gives you *two* arcs
One colour produces two arcs on opposite sides of the ring, not one. That is
the measured reference behaviour — it rotates a linear gradient whose two ends
are both bright, so the ring always has two hot spots 180° apart — and one lone
highlight chasing around looks nothing like it.
`shimmerColors` sets the arc count: three colours give three arcs 120° apart.
```astro
<ShimmerBorderCard
title="Three arcs"
shimmerColors={["#7dd3fc", "#f0abfc", "#fde68a"]}
shimmerSpeed={6000}
/>
```
### Unsafe links render inert
`href` is treated as untrusted, because it usually arrives from a CMS. Only
plain paths, fragments, and `http` / `https` / `mailto` / `tel` survive.
Control characters are stripped **before** the scheme is examined, so
`java<TAB>script:` is caught too.
A refused `href` does not throw and does not blank the card: it renders as a
`<div>` with all its content intact and no `href` attribute, and logs a warning
in development. Verified — `javascript:`, `data:text/html` and a tab-interrupted
`javascript:` all rendered as inert `<div>`s with no dialog fired and no global
set.
Caller-supplied **CSS** values (`shimmerColor`, `shimmerColors`,
`borderRadius`) are validated too. They land in an inline `style` attribute,
where a semicolon would start a new declaration and `url(…)` in a declaration
is an outbound network request. Both are refused, and a rejected value falls
back to the default rather than being partially applied.
## Styling
Every measured value is a custom property on the card root. They are declared
inside `:where()`, which means **specificity 0** — so your own class wins
without needing `!important` or a doubled selector:
```css
.my-card {
--sbc-max-width: 720px;
--sbc-card-padding: 16px;
--sbc-shimmer-duration: 6000ms;
}
```
```astro
<ShimmerBorderCard class="my-card" title="Wide" body="…" />
```
This matters more than it looks. Astro scopes component styles by appending a
`[data-astro-cid-…]` attribute, so an ordinary `.sbc { … }` rule ships at
specificity 0,2,0 and a consumer's single class silently loses to it. That was
measured during development: a `720px` override rendered at `384px` until the
token block moved inside `:where()`.
| Property | Default | What it controls |
| :--- | :--- | :--- |
| `--sbc-radius` | `8px` | Outer corner radius |
| `--sbc-border-width` | `2px` | Ring thickness |
| `--sbc-max-width` | `384px` | Cap; the card is fluid below it |
| `--sbc-shimmer-duration` | `3500ms` | One revolution |
| `--sbc-shimmer-easing` | `linear` | Timing function of the sweep |
| `--sbc-shimmer-color` | `#c6d2ff` | Arc colour |
| `--sbc-shimmer-highlight` | = arc colour | Hotter core at each arc's centre |
| `--sbc-shimmer-rest-opacity` | `0.55` | Sweep brightness when idle |
| `--sbc-shimmer-hover-opacity` | `1` | Sweep brightness on hover/focus |
| `--sbc-ring-bg` | `#1d293d` | The ring under the sweep |
| `--sbc-card-bg` | `#0f172b` | Panel background |
| `--sbc-card-bg-hover` | `#1d293d` | Panel background on hover |
| `--sbc-card-padding` | `32px` | Panel padding |
| `--sbc-title-size` / `--sbc-title-line` | `30px` / `36px` | Heading type |
| `--sbc-title-gap` | `16px` | Space below the heading |
| `--sbc-body-size` / `--sbc-body-line` | `16px` / `24px` | Body type |
| `--sbc-text-color` | `#f8fafc` | Heading colour |
| `--sbc-body-color` | `#90a1b9` | Paragraph colour |
| `--sbc-hover-duration` / `--sbc-hover-easing` | `500ms` / `cubic-bezier(0.4, 0, 0.2, 1)` | Hover transitions |
| `--sbc-hover-scale` | `1.01` | Hover lift |
| `--sbc-focus-ring` | `#c6d2ff` | `:focus-visible` outline colour |
### The panel must stay opaque
The sweep is confined to the ring by **occlusion**: the panel is painted on top
of it and covers everything except the `2px` edge. That is how the reference
does it, and it needs no `mask-composite` support to work.
The consequence is the one real constraint here: a translucent
`--sbc-card-bg` will let the arcs show faintly through the card interior. If
you want a see-through card, that is the trade-off to know about.
## Layout
`width: 100%` up to `max-width: 384px`, with the height coming from the
content. Nothing is aspect-ratio locked.
Measured, no horizontal overflow at any of them:
| Viewport | Card width |
| :--- | :--- |
| 1440 / 1280 / 1024 / 834 | 384px (capped) |
| 390 | 351px |
| 320 | 288px |
| 200% zoom (720×450 @2×) | 384px |
The sweep never touches layout. Across 628 consecutive frames of a full
revolution the card's bounding box was measured at exactly one distinct value.
### Any aspect ratio
The sweep is a conic gradient, not a rotating element, so it fills the box at
every angle whatever the shape. A 720×120 card — 6:1 — keeps an unbroken border
all the way round. This is a deliberate departure from the reference, which
rotates a square scaled to 1.75×; that only works because its card is square,
and tears away from the short edges on a wide one.
## Behaviour
### The sweep
Continuous and infinite, `3500ms` per revolution, `linear`. It loops with no
reset frame — the `0°` and `360°` renders are byte-identical PNGs, because the
stop list begins and ends on a transparent stop.
`shimmer={false}` holds the same gradient still rather than removing it, so the
card keeps a deliberate border instead of losing one.
### Hover and focus
Hover and `:focus-visible` do the same four things over `500ms`: the sweep goes
to full brightness, the card scales to `1.01`, the panel lightens, and the
focus outline appears (keyboard only). Keyboard users get the full affordance,
not a lesser one.
The outline is a real `2px solid` outline at `3px` offset. It does **not** rely
on the shimmer, which is decorative, colour-dependent, and nearly still under a
motion preference.
### Reduced motion
Under `prefers-reduced-motion: reduce` the sweep stops dead and every
transition is removed. Verified: sampled across 2 seconds the angle held a
single value, `0deg`, and `getAnimations()` returned 0.
What does *not* happen is the card losing anything. The gradient border still
renders, held at its initial angle, and hover and focus still change its
brightness.
### Without JavaScript
Identical, because there is no JavaScript. The rendered preview document
contains **zero** `<script>` tags. With script execution disabled the card, its
heading, its paragraph and its sweep layer all render as normal.
There is nothing to tear down on `astro:before-swap` and nothing to
reinitialise on `astro:page-load`, which is why it enrols as `static`.
### Two on one page
Independent. Each instance carries its own inline tokens, so two cards can run
at different speeds through different palettes side by side — measured at
`3.5s` and `6s` with different resolved gradients. There are no generated ids
and nothing shared between instances.
## Notes on the reference
Built from measurements of hover.dev's shimmer border card, taken from the live
page in Chrome. None of its code was read or reused; the numbers below were
read off the rendered result.
| | Reference (measured) | Here |
| :--- | :--- | :--- |
| Radius / ring | 8px / 2px padding | same |
| Inner radius | 7px (hardcoded) | `radius − width/2` → 7px |
| Panel padding | 32px | same |
| Ring / panel / page | `#1d293d` / `#0f172b` / `#020618` | same |
| Arc colour | `#c6d2ff` | same |
| Revolution | 3.500s | same |
| Easing | linear (fit deviation 0.115° over 826 frames) | same |
| Hover | scale 1.01, panel lightens, 500ms `cubic-bezier(0.4, 0, 0.2, 1)` | same |
| Mechanism | rotating `scale(1.75)` square, framer-motion JS loop | conic gradient, CSS `@property` |
| At rest | shimmer at `opacity: 0` | `opacity: 0.55` |
| Reduced motion | **ignored** — kept rotating at 102.86°/s | sweep stops |
| Keyboard | card is an unfocusable `<div>` | real `<a>` with a focus outline |
### Three deliberate differences
**It is visible at rest.** The reference holds the shimmer at `opacity: 0` until
hover, so the effect does not exist for touch users, does not exist in a
screenshot, and cannot be judged in a gallery. Setting
`--sbc-shimmer-rest-opacity: 0` reproduces the hover-only behaviour exactly.
**It honours reduced motion.** The reference does not — that was measured, not
assumed. Under `prefers-reduced-motion: reduce` its border kept turning at the
same 102.86°/s.
**It can be a link.** The reference card is a `div` with no focusable content,
so it has no focus state at all. Here, `href` makes the whole card a real `<a>`
with an accessible name from its content and a visible focus outline.
### What was not reproduced
The reference's icon, and its `#615fff` icon stroke. The component takes no
icon prop by design — put one in the slot. Its demo copy and its SVG are its
own and are not reused here.
===== END src/components/ShimmerBorderCard.md =====
--- 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.