Component library
Back to catalog

Shimmer border card

A dark card whose hairline border carries bright arcs sweeping continuously around the perimeter, brightening on hover and on keyboard focus.

cardborderanimationcss-onlylink

Open preview in a new tab

Use this component

Three ways to get Shimmer border card into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Shimmer border card from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/shimmer-border-card/

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

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.

Install kit

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.

Component reference

A fragment for 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 branding and content open. It does not carry the code.

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.

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
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
33 verification notes
  • 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.

Open the verification fixture

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>