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: "Centered card carousel"
Library page: https://astro.baysixmedia.com/components/centered-card-carousel/
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/CenteredCardCarousel.astro =====
---
/**
* CenteredCardCarousel — a full-bleed carousel whose active card sits centred
* with its neighbours peeking in from both edges, driven by arrows, dots,
* keyboard, mouse drag or touch swipe, looping in both directions.
*
* One self-contained file. No imports, no global stylesheet, no npm packages,
* no carousel library, no webfonts, no images, no network requests: drop it
* into any Astro project and render it. Static-only — no SSR, no endpoints.
*
* Design behaviour is modelled on the public Ome Duo Groningen "upcoming
* events" carousel (https://omeduogroningen.nl/), observed in a browser at
* several widths and rebuilt from those measurements. No source, markup, CSS,
* script, branding, artwork, copy or destination URL from that site is used
* here. See CenteredCardCarousel.md.
*
* Measured from the reference at 1363px and reproduced as tokens:
*
* slide 81.6% of the viewport, so ~125px of each neighbour shows
* card 1086x397, #16151b, 22.5px radius, 18px padding
* media 564x361, object-fit: cover, 18px radius, ~52% of the card
* title 31.6px / weight 900
* meta+body 16.2px, #c1c1c1
* actions 47px pill, muted purple #8879c9, white secondary
* arrows 43x43 at ~42px from each viewport edge, below the card
* dots 9px, centred, active #6643ff
* row->col between 834px and 1024px
*
* The reference rotates each slide's own offset and keeps four rendered
* slides for two real events. This translates one track and clones slides at
* runtime instead: the visible result is the same, and it keeps every real
* slide in one place, which is what makes the tab order and the no-script
* fallback tractable.
*
* Several instances can share a page: every query is scoped to the instance
* root, ids are minted per instance, and all state lives in the controller's
* closure.
*/
export interface CarouselMeta {
/** Short label, e.g. "When" or "Where". Optional. */
label?: string;
/** The value itself, e.g. "Fri 14 Mar, 21:00". */
value: string;
}
export interface CarouselAction {
label: string;
/** Destination. An unsafe or missing value renders as inert text. */
href: string;
/** `primary` is the filled pill; `secondary` the light one; `ghost` outlined. */
variant?: "primary" | "secondary" | "ghost";
/** Opens in a new tab with the matching rel. */
external?: boolean;
/** Overrides the accessible name where the label alone is not enough. */
ariaLabel?: string;
}
export interface CarouselItem {
/** Stable identity. Used for the slide's id and for keying clones. */
id: string;
/** Image URL. Omit for the built-in placeholder surface. */
image?: string;
/** Alt text. Empty string marks the image decorative. */
imageAlt?: string;
/** Small tracked line above the title. Optional. */
eyebrow?: string;
title: string;
/** Rows of supporting detail under the title. Optional. */
meta?: CarouselMeta[];
description?: string;
/** Zero or more calls to action. */
actions?: CarouselAction[];
}
export interface CenteredCardCarouselProps {
/** The cards. */
items: CarouselItem[];
/** Accessible name for the carousel region. */
label?: string;
/** Which item is centred on load, clamped into range. */
initialSlide?: number;
/** Wrap past the ends. Off makes the arrows disable at the extremes. */
loop?: boolean;
/** Show the previous/next arrows. */
showArrows?: boolean;
/** Show the pagination dots. */
showPagination?: boolean;
/** Advance on a timer. Off by default, as on the reference. */
autoplay?: boolean;
/** Milliseconds between automatic advances. */
autoplayInterval?: number;
/** Hold autoplay while the pointer is over it or focus is inside. */
pauseOnInteraction?: boolean;
/** Accessible name for the previous arrow. */
previousLabel?: string;
/** Accessible name for the next arrow. */
nextLabel?: string;
class?: string;
id?: string;
}
interface Props extends CenteredCardCarouselProps {}
const {
items,
label = "Featured cards",
initialSlide = 0,
loop = true,
showArrows = true,
showPagination = true,
autoplay = false,
autoplayInterval = 4000,
pauseOnInteraction = true,
previousLabel = "Previous slide",
nextLabel = "Next slide",
class: className,
id,
}: Props = Astro.props;
/*
* Item data is treated as untrusted: it typically comes from a CMS or a
* content collection, and an action href becomes an attribute on every render.
* Only plain paths, fragments and http/https/mailto/tel survive; anything else
* — `javascript:` and `data:` above all — renders as inert text rather than a
* link that runs.
*/
const SAFE_SCHEME = /^(https?:|mailto:|tel:)/i;
function safeHref(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const raw = value.trim();
if (raw === "") return undefined;
/* Control characters are stripped first: "java\tscript:" is a scheme too. */
const bare = [...raw]
.filter((ch) => {
const code = ch.codePointAt(0) ?? 0;
return code > 31 && code !== 127;
})
.join("")
.trim();
if (bare.startsWith("/") || bare.startsWith("#") || bare.startsWith("./") || bare.startsWith("../")) {
return bare;
}
if (SAFE_SCHEME.test(bare)) return bare;
const colon = bare.indexOf(":");
const slash = bare.indexOf("/");
if (colon === -1 || (slash !== -1 && slash < colon)) return bare;
return undefined;
}
/*
* An image source is a different risk from a link: it cannot navigate. What it
* can do is reach the network, which this component promises not to do on its
* own. Same-document `data:` URIs are allowed for that reason; a `javascript:`
* src is refused and falls back to the placeholder.
*/
function safeSrc(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const raw = value.trim();
if (raw === "") return undefined;
const bare = [...raw]
.filter((ch) => {
const code = ch.codePointAt(0) ?? 0;
return code > 31 && code !== 127;
})
.join("");
if (/^\s*javascript:/i.test(bare)) return undefined;
return raw;
}
/* A malformed entry is dropped rather than rendered as an empty card. */
const slides = (Array.isArray(items) ? items : [])
.filter(
(item): item is CarouselItem =>
!!item && typeof item.title === "string" && item.title.trim() !== "",
)
.map((item, index) => ({
key: typeof item.id === "string" && item.id.trim() !== "" ? item.id.trim() : `slide-${index}`,
image: safeSrc(item.image),
imageAlt: typeof item.imageAlt === "string" ? item.imageAlt : "",
eyebrow: typeof item.eyebrow === "string" ? item.eyebrow.trim() : "",
title: item.title.trim(),
meta: (Array.isArray(item.meta) ? item.meta : []).filter(
(row): row is CarouselMeta => !!row && typeof row.value === "string" && row.value.trim() !== "",
),
description: typeof item.description === "string" ? item.description.trim() : "",
actions: (Array.isArray(item.actions) ? item.actions : [])
.filter(
(action): action is CarouselAction =>
!!action && typeof action.label === "string" && action.label.trim() !== "",
)
.map((action) => ({
label: action.label.trim(),
href: safeHref(action.href),
variant: action.variant === "secondary" || action.variant === "ghost" ? action.variant : "primary",
external: action.external === true,
ariaLabel:
typeof action.ariaLabel === "string" && action.ariaLabel.trim() !== ""
? action.ariaLabel.trim()
: undefined,
})),
}));
const count = slides.length;
const start = Number.isFinite(initialSlide) ? Math.min(Math.max(Math.trunc(initialSlide), 0), Math.max(count - 1, 0)) : 0;
/* Random rather than sequential: a module-level counter is shared by every
render in the same server process, which is fine for one page and wrong the
moment two pages are built in parallel and compared. */
const uid = id ?? `ccc-${Math.random().toString(36).slice(2, 9)}`;
const trackId = `${uid}-track`;
const interval = Math.max(1200, Number(autoplayInterval) || 4000);
---
{
count > 0 && (
<section
class:list={["ccc", className]}
id={id}
data-ccc-root
data-ccc-start={String(start)}
data-ccc-loop={loop ? "1" : "0"}
data-ccc-autoplay={autoplay ? "1" : "0"}
data-ccc-interval={String(interval)}
data-ccc-pause={pauseOnInteraction ? "1" : "0"}
aria-roledescription="carousel"
aria-label={label}
>
{/*
Without the controller this is an ordinary horizontally scrollable
strip: every card is reachable by scrolling, by tabbing and by a
screen reader. The controller turns the scrolling off and takes
over the positioning.
*/}
<div class="ccc-viewport" data-ccc-viewport>
<ul class="ccc-track" id={trackId} data-ccc-track>
{slides.map((slide, index) => (
<li
class="ccc-slide"
data-ccc-slide
data-ccc-index={String(index)}
id={`${uid}-slide-${index}`}
aria-roledescription="slide"
aria-label={`${index + 1} of ${count}`}
>
<article class="ccc-card">
<div class="ccc-media">
{slide.image ? (
<img
class="ccc-img"
src={slide.image}
alt={slide.imageAlt}
loading={index === start ? "eager" : "lazy"}
decoding="async"
draggable="false"
/>
) : (
/* The intentional placeholder: a drawn surface rather than
a broken image icon. Decorative, so it is hidden from
assistive technology — the title carries the meaning. */
<div class="ccc-placeholder" aria-hidden="true">
<span class="ccc-placeholder-mark" />
</div>
)}
</div>
<div class="ccc-body">
<div class="ccc-head">
{slide.eyebrow && <p class="ccc-eyebrow">{slide.eyebrow}</p>}
<h3 class="ccc-title">{slide.title}</h3>
{slide.meta.length > 0 && (
<ul class="ccc-meta">
{slide.meta.map((row) => (
<li class="ccc-meta-row">
{row.label && <span class="ccc-meta-label">{row.label}</span>}
<span class="ccc-meta-value">{row.value}</span>
</li>
))}
</ul>
)}
</div>
<div class="ccc-foot">
{slide.description && <p class="ccc-desc">{slide.description}</p>}
{slide.actions.length > 0 && (
<div class="ccc-actions">
{slide.actions.map((action) =>
action.href ? (
<a
class:list={["ccc-action", `is-${action.variant}`]}
href={action.href}
aria-label={action.ariaLabel}
target={action.external ? "_blank" : undefined}
rel={action.external ? "noopener noreferrer" : undefined}
draggable="false"
>
{action.label}
</a>
) : (
<span class:list={["ccc-action", "is-inert", `is-${action.variant}`]}>
{action.label}
</span>
),
)}
</div>
)}
</div>
</div>
</article>
</li>
))}
</ul>
</div>
{(showArrows || showPagination) && count > 1 && (
<div class="ccc-controls" data-ccc-controls>
{showArrows ? (
<button class="ccc-arrow" type="button" data-ccc-prev aria-controls={trackId}>
<span class="ccc-sr">{previousLabel}</span>
<svg class="ccc-chev" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="M15 5 8 12l7 7"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
) : (
<span />
)}
{showPagination ? (
<ul class="ccc-dots" data-ccc-dots>
{slides.map((slide, index) => (
<li>
<button
class="ccc-dot"
type="button"
data-ccc-dot
data-ccc-goto={String(index)}
aria-controls={trackId}
aria-current={index === start ? "true" : undefined}
>
<span class="ccc-sr">{`Go to slide ${index + 1}: ${slide.title}`}</span>
<span class="ccc-dot-mark" aria-hidden="true" />
</button>
</li>
))}
</ul>
) : (
<span />
)}
{showArrows ? (
<button class="ccc-arrow" type="button" data-ccc-next aria-controls={trackId}>
<span class="ccc-sr">{nextLabel}</span>
<svg class="ccc-chev" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="m9 5 7 7-7 7"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
) : (
<span />
)}
</div>
)}
{/* Announces the change without stealing focus. Empty until the
controller runs, so it says nothing on a static page. */}
<p class="ccc-sr" aria-live="polite" data-ccc-live />
</section>
)
}
<style>
.ccc {
/* ---- overridable tokens -------------------------------------------
Set any of these on the element, from a class, an id or a wrapper
rule, to retune the component without touching this file. */
--ccc-surface: transparent;
--ccc-card-surface: #16151b;
--ccc-card-ink: #ffffff;
--ccc-card-muted: #c1c1c1;
--ccc-card-radius: clamp(18px, 1.7vw, 23px);
--ccc-card-padding: clamp(10px, 1.35vw, 18px);
--ccc-media-radius: clamp(14px, 1.35vw, 18px);
--ccc-media-ratio: 1.56;
--ccc-media-ratio-stacked: 1.46;
--ccc-media-width: 52%;
--ccc-accent: #8879c9;
--ccc-accent-ink: #ffffff;
--ccc-secondary: #ffffff;
--ccc-secondary-ink: #121212;
--ccc-arrow-surface: #16151b;
--ccc-arrow-ink: #ffffff;
--ccc-arrow-radius: 4px;
--ccc-arrow-inset: clamp(8px, 3vw, 42px);
--ccc-dot: #3b3b3b;
--ccc-dot-active: #6643ff;
--ccc-ring: #6643ff;
/* Slide width as a share of the viewport, measured from the reference:
81.6% at desktop leaves ~125px of each neighbour showing. */
--ccc-slide-width: 81.6%;
--ccc-gutter: 13px;
--ccc-duration: 520ms;
--ccc-ease: cubic-bezier(0.22, 1, 0.36, 1);
--ccc-font: inherit;
box-sizing: border-box;
display: block;
background: var(--ccc-surface);
font-family: var(--ccc-font);
/* A host page with `line-height: 1` would otherwise collapse every
vertical measurement inside the card. */
line-height: 1.5;
}
/* `:where` keeps specificity at zero, so a destination project's own rules
still win where it means them to. */
.ccc :where(*, *::before, *::after) {
box-sizing: border-box;
}
.ccc-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* ---------- viewport ---------- */
/*
* No script: an ordinary scroll strip with snap points, so every card is
* reachable and nothing is hidden. The controller swaps this for a
* translated track.
*/
.ccc-viewport {
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.ccc-viewport::-webkit-scrollbar {
display: none;
}
[data-ccc-ready] .ccc-viewport {
overflow: hidden;
scroll-snap-type: none;
cursor: grab;
user-select: none;
/* Vertical page scrolling stays with the page; only horizontal drags
belong to the carousel. */
touch-action: pan-y;
}
[data-ccc-ready][data-ccc-dragging] .ccc-viewport {
cursor: grabbing;
}
.ccc-track {
display: flex;
align-items: stretch;
margin: 0;
padding: 0;
list-style: none;
}
/*
* Centres the first and last card in the no-script scroll strip, so the
* composition reads the same before the controller arrives. Scoped to the
* un-booted component: as a rule that always matched, its padding shrank
* the flex container every slide's percentage width resolves against, and
* every card came out a sixth too narrow and pushed off centre.
*/
.ccc:not([data-ccc-ready]) .ccc-track {
padding-inline: calc((100% - var(--ccc-slide-width)) / 2);
}
[data-ccc-ready] .ccc-track {
transform: translate3d(var(--ccc-offset, 0px), 0, 0);
transition: transform var(--ccc-duration) var(--ccc-ease);
will-change: transform;
}
[data-ccc-ready][data-ccc-instant] .ccc-track,
[data-ccc-ready][data-ccc-dragging] .ccc-track {
transition: none;
}
.ccc-slide {
flex: 0 0 var(--ccc-slide-width);
width: var(--ccc-slide-width);
min-width: 0;
padding-inline: var(--ccc-gutter);
scroll-snap-align: center;
}
/* ---------- card ---------- */
.ccc-card {
display: flex;
gap: var(--ccc-card-padding);
height: 100%;
padding: var(--ccc-card-padding);
border-radius: var(--ccc-card-radius);
background: var(--ccc-card-surface);
color: var(--ccc-card-ink);
overflow: hidden;
}
.ccc-media {
flex: 0 0 var(--ccc-media-width);
min-width: 0;
}
/*
* Side by side, the media fills the row's height and `object-fit: cover`
* decides what of the picture survives — which is what the reference does,
* and what keeps the two columns the same height whatever the copy does.
* Stacked, the row axis flips and `--ccc-media-ratio` on the wrapper is
* what fixes the height instead.
*/
.ccc-img,
.ccc-placeholder {
display: block;
width: 100%;
height: 100%;
border-radius: var(--ccc-media-radius);
object-fit: cover;
}
.ccc-media {
aspect-ratio: var(--ccc-media-ratio);
}
/*
* The placeholder. Drawn from the component's own tokens rather than
* shipped as a file, so a missing image is a deliberate surface instead of
* a broken-image icon, and the component still makes no network request.
*/
.ccc-placeholder {
display: grid;
place-items: center;
background:
radial-gradient(
120% 120% at 20% 15%,
color-mix(in srgb, var(--ccc-accent) 55%, transparent),
transparent 60%
),
linear-gradient(
140deg,
color-mix(in srgb, var(--ccc-card-ink) 14%, transparent),
color-mix(in srgb, var(--ccc-card-ink) 4%, transparent)
);
}
.ccc-placeholder-mark {
width: clamp(28px, 4vw, 44px);
aspect-ratio: 1;
border: 2px solid color-mix(in srgb, var(--ccc-card-ink) 45%, transparent);
border-radius: 50%;
}
.ccc-body {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
justify-content: space-between;
gap: clamp(12px, 1.4vw, 20px);
padding: clamp(10px, 1.8vw, 27px) clamp(8px, 1.6vw, 24px);
}
.ccc-head,
.ccc-foot {
display: flex;
flex-direction: column;
gap: clamp(6px, 0.7vw, 10px);
min-width: 0;
}
.ccc-eyebrow {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ccc-card-muted);
}
.ccc-title {
margin: 0;
font-size: clamp(1.3rem, 0.85rem + 1.6vw, 2rem);
font-weight: 900;
line-height: 1.12;
letter-spacing: -0.01em;
/* A long title wraps and, if it still will not fit, breaks rather than
pushing the card wider than its track cell. */
overflow-wrap: anywhere;
}
.ccc-meta {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.ccc-meta-row {
display: flex;
flex-wrap: wrap;
gap: 0 0.5em;
font-size: clamp(0.9rem, 0.86rem + 0.2vw, 1.0625rem);
color: var(--ccc-card-muted);
min-width: 0;
overflow-wrap: anywhere;
}
.ccc-meta-label {
font-weight: 600;
color: color-mix(in srgb, var(--ccc-card-ink) 78%, transparent);
}
.ccc-desc {
margin: 0;
font-size: clamp(0.9rem, 0.86rem + 0.2vw, 1.0625rem);
color: var(--ccc-card-muted);
overflow-wrap: anywhere;
}
.ccc-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ccc-action {
display: inline-flex;
align-items: center;
justify-content: center;
/* 44px is the minimum target, not the height: a label long enough to
wrap grows the pill instead of spilling out of the card. Flex only
shrinks an item that is wider than the whole line, so ordinary
two- or three-word labels still sit on one line. */
min-height: 44px;
max-width: 100%;
padding: 8px clamp(16px, 1.6vw, 24px);
border-radius: 100px;
background: var(--ccc-accent);
color: var(--ccc-accent-ink);
font-size: clamp(0.9rem, 0.86rem + 0.2vw, 1.0625rem);
font-weight: 500;
line-height: 1.25;
text-align: center;
text-decoration: none;
overflow-wrap: anywhere;
transition: filter 180ms ease;
}
.ccc-action.is-secondary {
background: var(--ccc-secondary);
color: var(--ccc-secondary-ink);
}
.ccc-action.is-ghost {
background: transparent;
color: var(--ccc-card-ink);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ccc-card-ink) 35%, transparent);
}
.ccc-action:hover {
filter: brightness(1.08);
}
/* A refused href renders as visible, inert text rather than a link that
goes nowhere. */
.ccc-action.is-inert {
opacity: 0.55;
cursor: default;
}
/* ---------- controls ---------- */
.ccc-controls {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
margin-top: clamp(14px, 1.6vw, 22px);
padding-inline: var(--ccc-arrow-inset);
}
/* Arrows and dots do nothing until the controller is running, so they are
not shown until then. Without script the strip scrolls and snaps on its
own; offering controls that cannot move it would be worse than offering
none. */
.ccc:not([data-ccc-ready]) .ccc-controls {
display: none;
}
.ccc-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
/* The reference draws 43px; 44 is the accessible floor and the
difference is invisible. */
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: var(--ccc-arrow-radius);
background: var(--ccc-arrow-surface);
color: var(--ccc-arrow-ink);
font: inherit;
cursor: pointer;
}
.ccc-arrow[disabled] {
opacity: 0.35;
cursor: default;
}
.ccc-chev {
width: 20px;
height: 20px;
}
.ccc-dots {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
/*
* The reference's dot is a 9px target. The painted dot stays 9px here and
* the button around it is 44x44, so the look is unchanged and the target
* clears the accessible minimum.
*/
.ccc-dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.ccc-dot-mark {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--ccc-dot);
transition:
background-color 240ms ease,
transform 240ms var(--ccc-ease);
}
.ccc-dot[aria-current="true"] .ccc-dot-mark {
background: var(--ccc-dot-active);
transform: scale(1.15);
}
/* ---------- focus ---------- */
.ccc-action:focus-visible,
.ccc-arrow:focus-visible,
.ccc-dot:focus-visible {
outline: 2px solid var(--ccc-ring);
outline-offset: 3px;
border-radius: 8px;
}
.ccc-dot:focus-visible {
border-radius: 50%;
}
/* ---------- responsive ----------
Widths and the row-to-column switch are the measured reference values:
81.6% of the viewport at desktop, 72% at tablet, 92% on a phone, with
the card turning into a column between 834px and 1024px. */
@media (max-width: 899px) {
.ccc {
--ccc-slide-width: 72%;
--ccc-gutter: 10px;
}
.ccc-card {
flex-direction: column;
}
.ccc-media {
flex: 0 0 auto;
}
.ccc-media {
aspect-ratio: var(--ccc-media-ratio-stacked);
}
.ccc-img,
.ccc-placeholder {
height: 100%;
min-height: 0;
}
.ccc-body {
padding: clamp(8px, 2vw, 16px);
}
}
@media (max-width: 599px) {
.ccc {
--ccc-slide-width: 92%;
--ccc-gutter: 5px;
}
.ccc-actions {
gap: 6px;
}
.ccc-action {
flex: 1 1 auto;
}
}
/* ---------- reduced motion ----------
Every state still changes; it just arrives at once. Autoplay is switched
off entirely in the controller, so nothing moves unless asked. */
@media (prefers-reduced-motion: reduce) {
.ccc {
--ccc-duration: 1ms;
}
.ccc-dot-mark,
.ccc-action {
transition-duration: 1ms;
}
}
</style>
<script>
/**
* One controller per instance, one boot per page view.
*
* A carousel installed in a shared layout has to survive a destination
* project running <ClientRouter />: the document is swapped without this
* module being re-evaluated, the element is replaced, and anything left on
* `document` or `window` carries into the next route. So booting hangs off
* `astro:page-load` as well as first load, init is idempotent, and every
* listener is bound to an AbortController aborted before the swap.
*/
function initCenteredCardCarousel(root: HTMLElement) {
// Idempotent: a second boot over the same element is a no-op rather
// than a second set of listeners and a second set of clones.
if (root.dataset.cccReady === "") return;
const viewport = root.querySelector<HTMLElement>("[data-ccc-viewport]");
const track = root.querySelector<HTMLElement>("[data-ccc-track]");
if (!viewport || !track) return;
const real = [...track.querySelectorAll<HTMLElement>("[data-ccc-slide]")];
const count = real.length;
if (count === 0) return;
root.dataset.cccReady = "";
const controller = new AbortController();
const { signal } = controller;
const loop = root.dataset.cccLoop === "1" && count > 1;
const wantsAutoplay = root.dataset.cccAutoplay === "1" && count > 1;
const interval = Number(root.dataset.cccInterval) || 4000;
const pauseOnInteraction = root.dataset.cccPause === "1";
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const prevBtn = root.querySelector<HTMLButtonElement>("[data-ccc-prev]");
const nextBtn = root.querySelector<HTMLButtonElement>("[data-ccc-next]");
const dots = [...root.querySelectorAll<HTMLButtonElement>("[data-ccc-dot]")];
const live = root.querySelector<HTMLElement>("[data-ccc-live]");
/*
* Clones exist only while the controller runs, which is what keeps the
* no-script DOM free of duplicate cards and duplicate ids. One clone
* each side is enough: only one neighbour is ever visible.
*
* Every clone is hidden from assistive technology and every focusable
* inside it is taken out of the tab order, so the duplicates never
* become a second set of keyboard stops.
*/
const clones: HTMLElement[] = [];
function makeClone(source: HTMLElement, index: number) {
const clone = source.cloneNode(true) as HTMLElement;
clone.removeAttribute("id");
clone.setAttribute("aria-hidden", "true");
clone.dataset.cccClone = "";
clone.dataset.cccIndex = String(index);
clone.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id"));
clone
.querySelectorAll<HTMLElement>("a[href], button, input, select, textarea, [tabindex]")
.forEach((el) => el.setAttribute("tabindex", "-1"));
clones.push(clone);
return clone;
}
if (loop) {
track.append(makeClone(real[0], 0));
track.prepend(makeClone(real[count - 1], count - 1));
}
/* Position is an index into the rendered track, which is offset by one
when a leading clone exists. `index` stays the real, user-facing one. */
const offsetForClone = loop ? 1 : 0;
let index = Math.min(Math.max(Number(root.dataset.cccStart) || 0, 0), count - 1);
let position = index + offsetForClone;
let slideWidth = 0;
function measure() {
const slide = track!.querySelector<HTMLElement>("[data-ccc-slide]");
slideWidth = slide ? slide.getBoundingClientRect().width : 0;
}
/*
* Centre the slide at `position`: shift the track left by the slides
* before it, then right by the empty half-viewport on either side.
*/
function offsetFor(pos: number, drag = 0) {
const centring = (viewport!.clientWidth - slideWidth) / 2;
return -pos * slideWidth + centring + drag;
}
function apply(drag = 0) {
root.style.setProperty("--ccc-offset", `${offsetFor(position, drag)}px`);
}
/* `instant` suppresses the transition for the loop jump, so the wrap has
no visible snap: the clone slides in normally, then the track is
repositioned onto the real slide in the same frame it settles. */
function setInstant(on: boolean) {
root.toggleAttribute("data-ccc-instant", on);
if (on) void track!.offsetWidth; /* flush, so the next change animates */
}
function syncState() {
dots.forEach((dot, i) => {
if (i === index) dot.setAttribute("aria-current", "true");
else dot.removeAttribute("aria-current");
});
/* Only the centred slide keeps its links in the tab order. The
neighbours are visible but half off-screen, and tabbing into
something the user cannot see is how focus gets lost. */
[...track!.querySelectorAll<HTMLElement>("[data-ccc-slide]")].forEach((slide) => {
/* `dataset.cccClone` is the empty string on a clone, which is falsy,
so it has to be tested by presence rather than by truthiness. */
const isActive =
!slide.hasAttribute("data-ccc-clone") && Number(slide.dataset.cccIndex) === index;
slide.toggleAttribute("data-ccc-active", isActive);
slide
.querySelectorAll<HTMLElement>("a[href], button")
.forEach((el) => {
if (isActive) el.removeAttribute("tabindex");
else el.setAttribute("tabindex", "-1");
});
});
if (!loop) {
if (prevBtn) prevBtn.disabled = index === 0;
if (nextBtn) nextBtn.disabled = index === count - 1;
}
if (live) live.textContent = `Slide ${index + 1} of ${count}`;
}
let settleTimer: number | undefined;
function goTo(next: number, immediate = false) {
if (count < 2) return;
if (loop) {
position = next + offsetForClone;
index = ((next % count) + count) % count;
} else {
index = Math.min(Math.max(next, 0), count - 1);
position = index;
}
setInstant(immediate || reduced.matches);
apply();
syncState();
/*
* After the move settles, if we are sitting on a clone, jump to the
* real slide with the transition off. Driven by a timer rather than
* `transitionend` so a rapid sequence of clicks cannot leave a
* pending handler that fires against a stale position.
*/
window.clearTimeout(settleTimer);
const settle = immediate || reduced.matches ? 0 : Number.parseFloat(
getComputedStyle(root).getPropertyValue("--ccc-duration"),
) || 520;
settleTimer = window.setTimeout(() => {
if (!loop) return;
const lower = offsetForClone;
const upper = offsetForClone + count - 1;
if (position < lower || position > upper) {
position = index + offsetForClone;
setInstant(true);
apply();
/* Back to animating for whatever comes next. */
requestAnimationFrame(() => setInstant(false));
} else {
setInstant(false);
}
}, settle + 20);
}
const step = (delta: number) => goTo(loop ? position - offsetForClone + delta : index + delta);
prevBtn?.addEventListener("click", () => { stopAuto(); step(-1); }, { signal });
nextBtn?.addEventListener("click", () => { stopAuto(); step(1); }, { signal });
dots.forEach((dot) =>
dot.addEventListener(
"click",
() => {
stopAuto();
goTo(Number(dot.dataset.cccGoto) || 0);
},
{ signal },
),
);
/* Arrow keys work whenever focus is inside the carousel, but never
while focus is in a text field the host may have slotted in. */
root.addEventListener(
"keydown",
(event) => {
const target = event.target as HTMLElement | null;
if (target && target.closest("input, textarea, select, [contenteditable=true]")) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
stopAuto();
step(-1);
} else if (event.key === "ArrowRight") {
event.preventDefault();
stopAuto();
step(1);
}
},
{ signal },
);
/* ---- drag and swipe ---- */
let dragging = false;
let pointerId: number | null = null;
let startX = 0;
let startY = 0;
let delta = 0;
let moved = false;
/* Armed by a committed drag so the click it produces is not also
followed; see endDrag for why it expires on its own. */
let suppressClick = false;
let suppressTimer: number | undefined;
/* Below this a press is a click, not a drag — so ordinary taps on the
card's links still work. */
const THRESHOLD = 8;
viewport.addEventListener(
"pointerdown",
(event) => {
if (event.button !== 0 || count < 2) return;
dragging = true;
moved = false;
pointerId = event.pointerId;
startX = event.clientX;
startY = event.clientY;
delta = 0;
/* A new gesture supersedes any pending suppression from the
previous one, so a tap straight after a swipe still works. */
suppressClick = false;
window.clearTimeout(suppressTimer);
stopAuto();
},
{ signal },
);
/*
* The rest of the gesture is tracked on the window rather than on the
* viewport, and without pointer capture.
*
* A swipe that carries the finger past the edge of the card — which is
* most of them, since the card is only ~80% of the screen — otherwise
* stops producing events the moment the pointer leaves the element, and
* the `pointerup` that commits the slide never arrives at all. Capture
* was supposed to prevent that and did not hold for touch pointers, so
* the window is the reliable place to listen.
*/
window.addEventListener(
"pointermove",
(event) => {
if (!dragging || event.pointerId !== pointerId) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (!moved) {
if (Math.abs(dx) < THRESHOLD) return;
/* A mostly-vertical gesture belongs to the page, not to us. */
if (Math.abs(dy) > Math.abs(dx)) {
dragging = false;
return;
}
moved = true;
root.setAttribute("data-ccc-dragging", "");
}
delta = dx;
apply(delta);
},
{ signal },
);
function endDrag() {
if (!dragging) return;
dragging = false;
pointerId = null;
if (!moved) return;
moved = false;
root.removeAttribute("data-ccc-dragging");
/*
* A mouse drag is followed by a click on whatever sat under the
* pointer, so that click has to be swallowed. A touch swipe is not
* followed by one at all — so the suppressor cannot simply stay
* armed until a click arrives to clear it, or the next click to
* reach this element gets eaten instead, including the synthetic
* one a keyboard Enter fires on a focused link. It disarms itself.
*/
suppressClick = true;
window.clearTimeout(suppressTimer);
suppressTimer = window.setTimeout(() => {
suppressClick = false;
}, 400);
/* A quarter of a slide is enough to commit; anything less springs
back to where it was. */
const travelled = Math.abs(delta) > slideWidth * 0.25;
const direction = delta < 0 ? 1 : -1;
const current = loop ? position - offsetForClone : index;
delta = 0;
goTo(travelled ? current + direction : current);
}
window.addEventListener("pointerup", endDrag, { signal });
window.addEventListener("pointercancel", endDrag, { signal });
/* A touch that the browser takes over for scrolling ends the drag too. */
window.addEventListener("touchcancel", endDrag, { signal });
/* A drag that ended over a link must not also follow it. */
viewport.addEventListener(
"click",
(event) => {
if (!suppressClick) return;
suppressClick = false;
window.clearTimeout(suppressTimer);
event.preventDefault();
event.stopPropagation();
},
{ capture: true, signal },
);
viewport.addEventListener("dragstart", (event) => event.preventDefault(), { signal });
/* ---- autoplay ---- */
let timer: number | undefined;
let hovering = false;
let focused = false;
/* Reduced motion means nothing moves unless the user asks it to. */
const autoplayAllowed = () => wantsAutoplay && !reduced.matches;
function canRun() {
if (!autoplayAllowed()) return false;
if (document.hidden) return false;
if (dragging) return false;
if (pauseOnInteraction && (hovering || focused)) return false;
return true;
}
function startAuto() {
stopAuto();
if (!canRun()) return;
timer = window.setInterval(() => step(1), interval);
}
function stopAuto() {
if (timer !== undefined) window.clearInterval(timer);
timer = undefined;
}
const refreshAuto = () => (canRun() ? startAuto() : stopAuto());
if (wantsAutoplay) {
root.addEventListener("pointerenter", () => { hovering = true; refreshAuto(); }, { signal });
root.addEventListener("pointerleave", () => { hovering = false; refreshAuto(); }, { signal });
root.addEventListener("focusin", () => { focused = true; refreshAuto(); }, { signal });
root.addEventListener(
"focusout",
(event) => {
if (!root.contains(event.relatedTarget as Node)) {
focused = false;
refreshAuto();
}
},
{ signal },
);
document.addEventListener("visibilitychange", refreshAuto, { signal });
}
/* ---- layout ---- */
let resizeFrame = 0;
window.addEventListener(
"resize",
() => {
if (resizeFrame) return;
resizeFrame = requestAnimationFrame(() => {
resizeFrame = 0;
measure();
setInstant(true);
apply();
requestAnimationFrame(() => setInstant(false));
});
},
{ passive: true, signal },
);
reduced.addEventListener("change", refreshAuto, { signal });
measure();
setInstant(true);
apply();
syncState();
requestAnimationFrame(() => setInstant(false));
refreshAuto();
/*
* Astro replaces the document on a client-side navigation. Stop the
* timer, drop the clones and the inline offset so nothing is left
* behind, detach every listener, and clear the ready flag so a re-used
* element can be booted again.
*/
document.addEventListener(
"astro:before-swap",
() => {
stopAuto();
window.clearTimeout(settleTimer);
window.clearTimeout(suppressTimer);
if (resizeFrame) cancelAnimationFrame(resizeFrame);
clones.forEach((clone) => clone.remove());
root.style.removeProperty("--ccc-offset");
root.removeAttribute("data-ccc-instant");
root.removeAttribute("data-ccc-dragging");
controller.abort();
delete root.dataset.cccReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-ccc-root]").forEach(initCenteredCardCarousel);
/* Astro's own <script> is a deferred module, so the DOM is already parsed
by the time this runs. The readyState check is for the case where this
file is copied into a project that inlines it some other way. */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
/* Fires on every view transition in a project using <ClientRouter />, and
never at all in one that does not. Registered once per module, so it
cannot accumulate. */
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/CenteredCardCarousel.astro =====
===== FILE (supporting): src/components/CenteredCardCarousel.md =====
# Centered card carousel
A full-bleed carousel whose active card sits centred in the viewport with its
neighbours peeking in from both edges. It moves with arrows, pagination dots,
the arrow keys, a mouse drag or a touch swipe, and it loops in both directions
without a visible snap.
Each card is an image beside a block of content — eyebrow, title, rows of
supporting detail, a description, and zero or more actions that are ordinary
links. Below about 1024px the image moves above the content instead of beside
it, because the horizontal layout has run out of room rather than because a
breakpoint said so.
Catalog page: `/components/centered-card-carousel`
## Purpose
For a strip of things a visitor is meant to browse rather than scan: sessions,
events, releases, case studies. The centred composition is the point — one card
is clearly the subject, and the partial neighbours on both sides say, without a
label, that there is more in both directions.
> **Design credit.** The geometry and movement were modelled on the public
> [Ome Duo Groningen](https://omeduogroningen.nl/) "upcoming events" carousel,
> which was observed in a browser at several widths and rebuilt from those
> measurements. **No source code, markup, CSS, script, branding, logo, artwork,
> photography, event copy or destination URL from that site is used here.** The
> demo content in this catalog is written for this component. Differences are
> listed under [Measurable differences from the reference](#measurable-differences-from-the-reference).
## Files required
**`CenteredCardCarousel.astro`** — one file, the whole component.
- No imports — not from this catalog, not from anywhere.
- No npm packages. No Swiper, Embla, Splide, Glide, Flickity or GSAP. No React,
Vue or Svelte. No Tailwind, no CSS reset, no utility classes.
- No global stylesheet. Every colour, size, ratio, duration and easing is
declared on its own root element as a `--ccc-*` custom property, so it
inherits nothing from the host page and leaks nothing back.
- No webfonts, no bundled images, **no network requests of any kind**. Images
are whatever you pass in `image`; pass nothing and you get the built-in
placeholder surface.
- The only assumption about the host is that Astro compiles `.astro` files.
`CenteredCardCarousel.md` — this file. Documentation, not required to run.
## Basic usage
```astro
---
import CenteredCardCarousel from "../components/library/CenteredCardCarousel.astro";
const sessions = [
{
id: "aurora",
image: "/images/aurora.jpg",
imageAlt: "The main hall lit in violet before doors open",
eyebrow: "Live session",
title: "Aurora Sessions: Opening Night",
meta: [
{ label: "When", value: "Fri 14 Mar, 21:00" },
{ label: "Where", value: "North Hall, Building C" },
],
description:
"An opening night built around a single long set, with the room reconfigured so nobody is more than twenty metres from the stage.",
actions: [
{ label: "Get tickets", href: "/tickets/aurora/" },
{ label: "Details", href: "/events/aurora/", variant: "secondary" },
],
},
// ...
];
---
<CenteredCardCarousel items={sessions} label="Upcoming sessions" />
```
That is the whole integration. No client directive, no wrapper, no provider,
no init call — the component ships its own module script and boots itself.
With autoplay, no dots, and a specific starting card:
```astro
<CenteredCardCarousel
items={sessions}
label="Upcoming sessions"
initialSlide={2}
autoplay
autoplayInterval={6000}
showPagination={false}
/>
```
## Props
`items` is required; everything else has a default.
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `CarouselItem[]` | — | The cards. Rendering is skipped entirely if this is empty. |
| `label` | `string` | `"Featured cards"` | Accessible name for the carousel region. |
| `initialSlide` | `number` | `0` | Which item is centred on load. Clamped into range; a non-finite value falls back to `0`. |
| `loop` | `boolean` | `true` | Wrap past the ends. `false` disables the arrows at the extremes instead. |
| `showArrows` | `boolean` | `true` | Show the previous/next arrows. |
| `showPagination` | `boolean` | `true` | Show the pagination dots. |
| `autoplay` | `boolean` | `false` | Advance on a timer. Off by default, as on the reference. |
| `autoplayInterval` | `number` | `4000` | Milliseconds between advances. Floored at `1200` so a mistake cannot produce a strobe. |
| `pauseOnInteraction` | `boolean` | `true` | Hold autoplay while the pointer is over it, focus is inside it, or a drag is in progress. |
| `previousLabel` | `string` | `"Previous slide"` | Accessible name for the previous arrow. |
| `nextLabel` | `string` | `"Next slide"` | Accessible name for the next arrow. |
| `class` | `string?` | — | Extra class on the `<section>`. |
| `id` | `string?` | — | Section id. Also seeds the internal ids instead of a random value. |
The interface is deliberately narrow. Slide width, gutter, card padding, media
ratio, arrow inset and dot size are **not** props — they are CSS custom
properties, because that is what they are, and exposing every internal
measurement as a prop would make the API a worse copy of the stylesheet. See
[CSS custom properties](#css-custom-properties).
### `CarouselItem`
| Field | Type | What it does |
| :--- | :--- | :--- |
| `id` | `string` | Stable identity. Used for the slide's id and for keying clones. Items without one are dropped. |
| `image` | `string?` | Image URL. Omit for the built-in placeholder surface. |
| `imageAlt` | `string?` | Alt text. An empty string marks the image decorative, which is correct when the title already says what it shows. |
| `eyebrow` | `string?` | Small tracked line above the title. |
| `title` | `string` | The card's heading. Items without one are dropped. |
| `meta` | `CarouselMeta[]?` | Rows of supporting detail under the title. |
| `description` | `string?` | Body copy. |
| `actions` | `CarouselAction[]?` | Zero or more calls to action. |
### `CarouselMeta`
| Field | Type | What it does |
| :--- | :--- | :--- |
| `label` | `string?` | Short label, e.g. `"When"`. Rendered as the row's term. |
| `value` | `string` | The value, e.g. `"Fri 14 Mar, 21:00"`. |
### `CarouselAction`
| Field | Type | What it does |
| :--- | :--- | :--- |
| `label` | `string` | Visible text. An entry without one is dropped. |
| `href` | `string` | Destination. An unsafe or missing value renders as plain inert text, never a link. |
| `variant` | `"primary"`, `"secondary"` or `"ghost"` (optional) | `primary` is the filled pill, `secondary` the light one, `ghost` outlined. Defaults to `primary`. |
| `external` | `boolean?` | Opens in a new tab with `rel="noopener noreferrer"`. |
| `ariaLabel` | `string?` | Overrides the accessible name where the label alone is not enough — "Details" on four cards is four identical link names otherwise. |
### Item data is validated
Item data usually comes from a CMS or a content collection, and an action
`href` becomes an attribute on every render. Every value is checked before it
becomes one:
- **Allowed:** root-relative (`/tickets/`), bare relative (`tickets/`),
fragments (`#lineup`), `./` and `../`, and `http:`, `https:`, `mailto:`,
`tel:`.
- **Refused:** everything else, including `javascript:`, `data:` and `vbscript:`
in any casing, with leading whitespace, or with control characters inserted
mid-scheme. Control characters are stripped before the scheme test, so that
trick does not get past it.
- A refused `href` renders the label as **plain text** — visible and inert,
rather than silently disappearing or becoming a link that runs.
- `image` goes through a narrower check that refuses `javascript:` but allows
`data:` URIs, because an inline SVG placeholder is a legitimate image source.
- Items that are `null`, or have no `id` or no `title`, are dropped.
- Text is rendered with `set:text` throughout. There is no `set:html` anywhere
in the component, and no prop accepts markup.
## Named slots
None. A carousel's contents are its items, and items are data — a slot would
only be renderable once, which is the wrong shape for a list.
## CSS custom properties
Set any of these on the element — with `class`, an `id`, or a wrapper rule — to
retune it without touching the file.
| Property | Default | What it controls |
| :--- | :--- | :--- |
| `--ccc-surface` | `transparent` | Background behind the whole carousel |
| `--ccc-card-surface` | `#16151b` | Card fill |
| `--ccc-card-ink` | `#ffffff` | Card text |
| `--ccc-card-muted` | `#c1c1c1` | Meta and description text |
| `--ccc-card-radius` | `clamp(18px, 1.7vw, 23px)` | Card corner radius |
| `--ccc-card-padding` | `clamp(10px, 1.35vw, 18px)` | Card inner padding |
| `--ccc-media-radius` | `clamp(14px, 1.35vw, 18px)` | Image corner radius |
| `--ccc-media-ratio` | `1.56` | Image aspect ratio token (see limitations) |
| `--ccc-media-ratio-stacked` | `1.46` | Image aspect ratio once the card stacks |
| `--ccc-media-width` | `52%` | Image share of the card in the row layout |
| `--ccc-accent` | `#8879c9` | Primary action fill |
| `--ccc-accent-ink` | `#ffffff` | Primary action text |
| `--ccc-secondary` | `#ffffff` | Secondary action fill |
| `--ccc-secondary-ink` | `#121212` | Secondary action text |
| `--ccc-arrow-surface` | `#16151b` | Arrow button fill |
| `--ccc-arrow-ink` | `#ffffff` | Arrow glyph colour |
| `--ccc-arrow-radius` | `4px` | Arrow corner radius |
| `--ccc-arrow-inset` | `clamp(8px, 3vw, 42px)` | Arrow distance from the viewport edge |
| `--ccc-dot` | `#3b3b3b` | Inactive dot |
| `--ccc-dot-active` | `#6643ff` | Active dot |
| `--ccc-ring` | `#6643ff` | Focus ring colour |
| `--ccc-slide-width` | `81.6%` (`72%` ≤899, `92%` ≤599) | Slide width as a share of the viewport — this is what sets how much of each neighbour shows |
| `--ccc-gutter` | `13px` | Space between slides |
| `--ccc-duration` | `520ms` | Slide transition duration |
| `--ccc-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | Slide transition easing |
| `--ccc-font` | `inherit` | Font stack. Inherits from the host page by default |
`--ccc-offset` is written by the controller on every move and must not be set
by hand.
A light-on-dark host page, for example:
```astro
<CenteredCardCarousel items={sessions} class="light-cards" />
<style is:global>
.light-cards {
--ccc-card-surface: #ffffff;
--ccc-card-ink: #101014;
--ccc-card-muted: #55555f;
--ccc-arrow-surface: #101014;
--ccc-dot: #cfcfd6;
}
</style>
```
## The placeholder surface
An item with no `image`, or with one the validator refuses, renders a built-in
placeholder: a tinted panel with a centred mark, drawn in CSS from the card's
own tokens. It occupies exactly the space a real image would, so a card with no
artwork is the same shape as one with artwork, and it is marked
`aria-hidden="true"` because it carries no information.
This is deliberate. An `<img>` with a missing `src` is a broken-image icon and a
layout that shifts; a placeholder is neither. The fourth card in this catalog's
demo has no image on purpose, so the placeholder is visible in the preview.
## Geometry
The centred composition is one translated track, not per-slide offsets:
- Every slide is `--ccc-slide-width` of the viewport.
- The track carries a padding of `(100% - slide width) / 2` on each side before
the controller boots, which centres the first card with no script at all.
- Once ready, the controller drops that padding and translates the track by
`-index * slideWidth + (viewportWidth - slideWidth) / 2`, which puts the
active card's centre exactly on the viewport's centre at every width.
Measured centring error across 1440, 1280, 1024, 834, 390, 320 and 200% zoom at
both a desktop and a mobile width: **0px at all eight**.
Neighbour peek — how much of the adjacent card is visible on each side:
| Viewport | Card width | % of viewport | Peek each side | Card layout |
| ---: | ---: | ---: | ---: | :--- |
| 1440 | 1149 | 79.8% | 132px | image beside content |
| 1280 | 1018 | 79.6% | 118px | image beside content |
| 1024 | 810 | 79.1% | 94px | image beside content |
| 834 | 580 | 69.6% | 117px | image above content |
| 390 | 349 | 89.4% | 16px | image above content |
| 320 | 284 | 88.9% | 13px | image above content |
At 390px and below the neighbour is a deliberate sliver rather than a preview:
one card has to be properly readable first, and the sliver is enough to say
there is more.
## Behaviour
| Input | What happens |
| :--- | :--- |
| Previous / next arrows | One card in that direction. |
| Pagination dots | Jump straight to that card. |
| `ArrowLeft` / `ArrowRight` | Previous / next, but only while focus is inside the carousel — the page's own arrow-key scrolling is untouched everywhere else. |
| Mouse drag | The track follows the pointer; releasing past 25% of a card width commits the move, anything less springs back. |
| Touch swipe | The same, with vertical scrolling left to the page. |
| Autoplay | Off by default. When on, it pauses on hover, on focus, during a drag, and while the tab is hidden. |
### Autoplay in detail
With `autoplay`, advances are driven by a plain interval, measured at
**1501 / 1495 / 1499 / 1505 ms against a configured 1500** — it is the interval
you set, not an approximation of it.
It is held — not merely skipped — whenever any of these is true:
- the pointer is over the carousel, or focus is inside it (`pauseOnInteraction`,
on by default);
- a drag is in progress;
- `document.hidden` is true, so a backgrounded tab does not silently cycle;
- `prefers-reduced-motion: reduce` is set, which disables it outright.
**Manual navigation pauses it too.** Using an arrow, a dot, a key or a drag
stops the timer, and it picks up again once the user disengages — that is, when
the pointer has left *and* focus is outside. Clicking an arrow leaves focus on
that arrow, so autoplay stays held until focus moves on; this is the intended
behaviour, not a stall.
`autoplayInterval` is floored at 1200ms. `pauseOnInteraction={false}` opts out
of the hover/focus holds only — visibility, dragging and reduced motion still
apply.
**The drag threshold is 8px.** Below that nothing has moved and the gesture is
still a click, so a card's links keep working under the pointer. Above it the
component takes over and suppresses the click the browser fires at the end of
the drag, so releasing over a link does not navigate.
That suppression **expires on its own after 400ms**, and a new gesture cancels
it. A mouse drag is followed by a click on whatever sat under the pointer, so
there is something to swallow; a touch swipe is followed by no click at all, so
a suppressor that waited for one would still be armed when the *next* click
arrived — including the synthetic click a keyboard `Enter` fires on a focused
link. Verified: after a committed swipe, `Enter` on a card link still follows it.
**A mostly-vertical gesture is handed back to the page.** If the first
significant movement is larger vertically than horizontally, the drag is
abandoned outright. `touch-action: pan-y` on the viewport means the browser
never waits on the component to decide about vertical scrolling in the first
place.
**The gesture is tracked on the window, not on the carousel.** A swipe that
carries the finger or cursor past the edge of the card — which is most of them,
since the card is around 80% of the screen — would otherwise stop producing
events the moment the pointer left the element, and the release that commits the
slide would never arrive.
**Looping has no blank and no snap.** Clones of the first and last slides sit at
each end of the track. Moving past an end lands on a clone, and once the
transition settles the track jumps to the matching real slide with transitions
suppressed for one frame. The jump is not observable: it happens at rest, at an
identical visual position.
**Repeated navigation cannot corrupt the index.** Clicking `next` seven times in
a row from card 1 of 4 lands on card 4 — verified — because the index is
advanced on input and the transition is a consequence, not the source of truth.
**Clones are never in the tab order.** Every focusable element inside a clone is
given `tabindex="-1"` and the clone is `aria-hidden="true"`, so a four-card
carousel offers each card's links exactly once. Clones also carry no `id`: ids
are stripped on clone, so the page cannot end up with duplicates.
## Edge cases
Verified behaviour for inputs that are legal but unusual:
| Input | What happens |
| :--- | :--- |
| `items={[]}` | Nothing renders at all — no `<section>`, no stray controls, no runtime error. A carousel of nothing is not an empty carousel. |
| One item | Renders the card, centred. No clones, and **no control row in the DOM** — there is nowhere to go, so there are no arrows and no dots to suggest otherwise. |
| Two items | Loops correctly in both directions (`0→1→0→1…`). Two clones, and only the active card's links are ever in the tab order, so the duplicates never become a second set of keyboard stops. |
| `loop={false}` | No clones. The arrow at each endpoint takes the native `disabled` state, which both stops the action and conveys it to assistive technology. Verified stopping at index 0 and index 3 and returning. |
| `showArrows={false}` | Dots only. |
| `showPagination={false}` | Arrows only. |
| Both `false` | The control row is not rendered — but **arrow keys, drag and swipe still work**, so the carousel is still fully operable. |
| `initialSlide={-3}` | Clamped to `0`. |
| `initialSlide={99}` | Clamped to the last index. |
| `initialSlide={2.7}` | Truncated to `2`. |
| `autoplayInterval={50}` | Floored to `1200`. |
| Items missing `id` or `title` | Dropped before render. |
## Accessibility
- The root is a `<section>` with `aria-roledescription="carousel"` and the
`label` prop as its accessible name.
- The track is a `<ul>`, each slide an `<li>`, each card an `<article>`.
- Arrows and dots are real `<button>`s. Actions are real `<a href>`s. Nothing
interactive is a `<div>` with a handler.
- Every dot carries `aria-current="true"` when it is the active slide, and a
visually hidden accessible name of the form "Go to slide 2: Longform — A Day
of Slow Craft", so the dots are distinguishable by name rather than only by
position.
- A visually hidden `aria-live="polite"` region announces "Slide 2 of 4" after
each move. It is written only by the controller, so a static page announces
nothing.
- Focus never lands inside a hidden or recycled slide: the loop jump happens at
rest between real slides, and the element that had focus is not the element
that gets recycled.
- **Every interactive target is at least 44×44px** — arrows are 44×44, and each
dot's 9px mark sits inside a 44×44 button whose padding is transparent.
- Focus indicators are visible on all controls, drawn with `--ccc-ring` and
never removed.
- With `loop={false}`, the arrows at the extremes are `disabled`, not just
visually dimmed.
## Reduced motion
Under `prefers-reduced-motion: reduce`:
- `--ccc-duration` drops to `1ms`, so every move arrives at once rather than
sliding. Verified: after a click the active card is centred within 90ms.
- **Autoplay is switched off entirely in the controller**, regardless of the
`autoplay` prop. Verified: with `autoplay` on and reduced motion set, the
index did not change over 1.5 seconds.
- Nothing moves unless the user asks it to. Arrows, dots, keyboard and drag all
still work — the change is that state arrives instead of travelling.
## Without JavaScript
The markup renders as an ordinary horizontally scrollable strip with CSS scroll
snapping:
- All four cards are in the DOM and visible — nothing is hidden behind a script.
- Zero clones, so no duplicate content and no duplicate ids.
- Every card's links are tabbable: 8 tabbable links across the demo's four
cards, the same count the interactive version offers.
- The strip scrolls horizontally with snap points, so every card is reachable.
- The first card is still centred, because the track's pre-boot padding does it
in CSS.
- The arrows and dots are only revealed once the controller marks the root
ready, so a scriptless page is never shown controls that cannot do anything.
Verified at 1280px and 390px with JavaScript disabled.
## Multiple instances
Any number can share a page:
- Every query is scoped to that instance's root element; nothing uses a bare
`document.querySelector` for component state.
- All state lives in the controller's closure, not on a module-level variable.
- Ids are minted per instance from a random suffix, or seeded from the `id`
prop when you give one. Random rather than a module counter, because a counter
is shared by every render in one server process and collides the moment two
pages are built in parallel.
- A `data-ccc-ready` flag makes initialisation idempotent, so booting twice is a
no-op rather than a second set of listeners.
Verified with two instances on one page: advancing the second left the first on
card 1, both were marked ready, and no id appeared twice.
## Astro navigation
The component supports `<ClientRouter />` without any work on your part:
- It boots on `DOMContentLoaded` and on `astro:page-load`.
- Every listener is registered with an `AbortController` signal.
- `astro:before-swap` aborts the controller, removes the clones, clears the
inline offset and clears the ready flag, so the element is handed back in the
state it was served in.
- No listener survives a navigation, and none accumulates across repeated ones.
- **No global body classes, no body-scroll locks, no permanent inline styles.**
The only inline style it writes is `--ccc-offset` on its own root, and that is
removed on teardown.
Verified: after `astro:before-swap` the clone count was 0 and the offset was
empty; after `astro:page-load` it rebooted, re-cloned and navigated correctly;
one live `astro:before-swap` listener remained, not a growing set.
Also verified **mid-flight**: tearing down while autoplay was running, a drag
was in progress and another instance was mid-transition left no live interval,
no clone, no inline offset, and no stranded `data-ccc-instant` or
`data-ccc-dragging` attribute on any of 14 instances. Nothing moved in the four
seconds after teardown, and a subsequent `astro:page-load` restored all 14.
## Long content
Titles, eyebrows, descriptions and meta values wrap with
`overflow-wrap: anywhere`, so a single unbroken 130-character token breaks
rather than pushing the card open. Action pills wrap too — they have a 44px
*minimum* height, not a fixed one, so a long label grows the pill instead of
spilling out of the card.
Verified at 1280, 834, 390 and 320 with a hostile card — a 220-character title,
a 570-character description, five meta rows and three 89-character action
labels: no element escaped the card bounds at any width, the page never
scrolled horizontally, the card stayed exactly centred, and navigation still
worked.
## Cloudflare / static compatibility
Nothing here needs a server:
- Output is static HTML plus one inline module script.
- No SSR, no endpoints, no adapter requirement, no runtime environment access.
- No `fetch`, no external stylesheet, no webfont, no third-party script.
Verified: zero non-local network requests originate from the component.
- Works identically on Cloudflare Pages, Netlify, Vercel static, GitHub Pages or
a plain file server.
## Copying it into another Astro project
1. Copy `src/components/library/CenteredCardCarousel.astro` into that project.
Anywhere under `src/` is fine; `src/components/` is conventional.
2. Import it and pass `items`. There is nothing else to install and nothing to
configure.
```astro
---
import CenteredCardCarousel from "../components/CenteredCardCarousel.astro";
---
<CenteredCardCarousel items={items} label="Upcoming sessions" />
```
3. Optionally set `--ccc-*` properties to match your palette.
The demo images in this catalog are inline SVG `data:` URIs written for this
component — they live in `src/data/components.ts`, not in the component, and
there is nothing in them to attribute. Point `image` at your own files, or leave
it off and take the placeholder.
## Measurable differences from the reference
Measured on the reference at 1363px, then compared with this component at the
same width.
| | Reference | Here | Why |
| :--- | :--- | :--- | :--- |
| Movement | Each slide carries its own transform; four slides are rendered for two real events and their offsets rotate | One track is translated; real slides stay put and clones are added at the ends | Keeping every real slide in one place is what makes the tab order and the no-script fallback tractable |
| Card action | Opens an in-page event modal | Configurable links through `actions` | A modal was explicitly out of scope, and links are portable and work without script |
| Slide width | 81.6% of the viewport | 81.6%, as a `--ccc-slide-width` token | Matched |
| Card | 1086×397, `#16151b`, 22.5px radius, 18px padding | Same, expressed with `clamp()` so it scales | Matched, made fluid |
| Image | 564×361 `cover`, 18px radius, ~52% of the card | Same, `--ccc-media-width: 52%` | Matched |
| Arrows | 43×43, ~42px from each edge, below the card | **44×44** at the same inset | 43px fails the 44px minimum target size; one pixel is not a visual difference |
| Dots | 9px, centred, active `#6643ff` | 9px mark inside a 44×44 button | The mark matches; the hit area does not, for the same reason |
| Row → column | Between 834px and 1024px | Same | Matched |
| Keyboard | Arrow keys do not drive the carousel | `ArrowLeft` / `ArrowRight` while focus is inside | An accessibility addition, scoped so it cannot steal the page's arrow keys |
| Live region | None | `aria-live="polite"` slide announcement | An accessibility addition |
| Reduced motion | Not honoured | Duration collapses, autoplay disabled | An accessibility addition |
| No script | Carousel does not render usefully | Scroll-snap strip with all cards reachable | A portability addition |
Every difference is an addition or a correction, not a simplification. The
visible desktop result — card size, colours, radii, image share, type scale,
arrow placement, dot treatment, neighbour peek and transition feel — is the
reference's.
## Known limitations
- **One card at a time.** There is no multi-card-per-view mode. The centred
composition is the design; a three-up variant would be a different component,
not a prop.
- **Equal-height slides.** Every slide is as tall as the tallest card, so one
card with far more copy than the others adds height to all of them. This is
what keeps the arrows and dots from moving as you navigate, and it is the
reference's behaviour too.
- **Clones double the DOM at the ends.** Two extra slides exist while the
controller is running. For a handful of cards that is nothing; for a hundred
cards with large images you would want virtualisation, which this does not do.
- **`--ccc-media-ratio` is inert in the row layout.** With the image beside the
content, the image's height is set by the row, matching the reference. The
ratio token only takes effect once the card stacks, where
`--ccc-media-ratio-stacked` applies.
- **No swipe velocity.** A commit is decided by distance travelled (25% of a
card), not by flick speed, so a very fast short flick does not advance. This is
predictable rather than clever, and it is what makes the 8px click threshold
safe.
===== END src/components/CenteredCardCarousel.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
NOT production ready in the library.
Verified 2026-09-15 at 1440, 1280, 1024, 834, 390, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, active state integrity, long content, loop integrity, clone inertness, pointer drag, touch swipe, click intent separation, autoplay pause, autoplay reduced motion.
Library source fingerprint: sha256:31a0bf993e19c8c75a27c0895d899f883b50a68feae62f504429cf6273ce7e98 — MATCHES the files below.
--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
- Lifecycle: not-run
- Loop transition continuity: fail
--- 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.