Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.
Interactivity
Ships script
Profile
carousel
Installation files
1 required + 1 supporting
Verified
2026-09-15
Client navigation
Event-tested only — not certified against a real router
Source fingerprint
Matches the verified source
Production ready
Not yet
Not production ready
Something required failed, is missing, or contradicts a declared fact.
Blocking
The final-to-first loop-boundary transition does not match normal slide transitions
Still to run
lifecycle (teardown and reinitialisation): not run
Verified 2026-09-15 at 1440, 1280, 1024, 834, 390, 320px
✓Full-page contextpass
✓Keyboardpass
✓No JavaScriptpass
✓Reduced motionpass
✓Two instancespass
✓No overflowpass
✓Declarations corroboratedpass
–Lifecyclenot-run
✓Active state integritypass
✓Long contentpass
✓Loop integritypass
✓Clone inertnesspass
✓Pointer dragpass
✓Touch swipepass
✓Click intent separationpass
✓Autoplay pausepass
✓Autoplay reduced motionpass
✗Loop transition continuityfail
Keyboard, no-JavaScript, reduced motion, two instances and overflow were verified at all six widths on the standalone preview document.
Carousel behaviour verified: arrows, dots, arrow keys, mouse drag with an 8px click threshold, touch swipe both directions, looping both directions, rapid repeated navigation, clone inertness, autoplay interval and pause, and long-content resilience.
Generic fixture: two instances with different starting slides stay independent, no duplicate ids, no horizontal overflow, no console errors and no external requests.
Declarations corroborated against the rendered fixture: 18 focusable controls against hasFocusableContent, 17 animating elements against hasMotion, and the id prop reaching the component against acceptsIdProp.
Known defect: the final real card to first card transition does not match an ordinary slide transition. Recorded as a failed check, which blocks readiness.
lifecycle is not-run. A real ClientRouter transition was exercised and showed clean teardown of clones, attributes and offsets, a genuinely client-side swap, reinitialisation and a working interaction afterwards — but AbortController signal disposal was not observed, so teardown is not proven and no lifecycle claim is made. The instrumentation that would prove it now exists in src/lib/lifecycle-probe.ts and has not been run against this component.
Three ways to get Centered card carousel into one of your own projects, all pre-filled with this component's name and library page.
Start here
Component reference
Paste it into a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving the branding and content open.
Read it first
“Centered card carousel” from my Astro Component Library:
https://astro.baysixmedia.com/components/centered-card-carousel/
Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.
The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.
ChatGPT brief
Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.
Read it first
Help me add this component to one of my Astro websites:
Component: Centered card carousel
Library page: https://astro.baysixmedia.com/components/centered-card-carousel
I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]
Place it:
[ENTER LOCATION OR SELECTOR]
Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]
Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.
Placement prompt
Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.
Read it first
Add Centered card carousel from my Astro Component Library to this Astro project.
Library page:
https://astro.baysixmedia.com/components/centered-card-carousel
First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.
Place it in:
[ENTER PAGE, FILE, OR ROUTE]
Position:
[ENTER WHERE IT SHOULD APPEAR]
Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.
Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.
--- VERIFIED IN THE COMPONENT LIBRARY ---
NOT production ready in the library.
Verified 2026-09-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.
src/components/library/CenteredCardCarousel.astro
one fileNo imports, no companion files, no packages. Copy CenteredCardCarousel.astro into any Astro project's components folder and render it.
---
/**
* 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>