The 6 files 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: "Story carousel"
Library page: https://astro.baysixmedia.com/components/story-carousel/
Files: 6 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/StoryCarousel.astro =====
---
/**
* StoryCarousel — a fanned stack of customer-story cards on a gradient band.
*
* One self-contained file. No imports, no global stylesheet, no build step
* beyond Astro itself: drop it into any Astro project and render it.
*
* Geometry and motion below are measured values, not guesses:
* active card 762x513 (>=1024) | 672x430 (>=768) | 342x538 (<768)
* inner peek 105x344 at every width
* outer peek 74x205, a sliver at >=768, not rendered below
* card movement 615ms cubic-bezier(0.4, 0, 0.2, 1)
* dots 8px tall, inactive 8px wide, active 80px over 300ms
* auto-advance 6000ms, forward, wrapping
* card content copy and portrait together fade and slide in from the right
* over 145ms after a 470ms delay, landing on the same frame
* the card's own movement ends
*
* Pausing on hover and on focus is a deliberate accessibility choice: an
* unpausable 6s carousel is hostile to anyone reading a quote or tabbing
* through it.
*
* Several instances can share a page — each one keeps its own selection,
* timer, dots and pause state.
*/
export interface Story {
/** Used for element ids. Falls back to the story's position. */
id?: string;
/** The large figure, e.g. "9 hours". */
stat: string;
/** The line under it, e.g. "back per person, every month". */
statLabel: string;
/** Quote text. Curly quotes are added around it. */
quote: string;
name: string;
role: string;
/**
* Portrait for this story. Any URL the host can serve. Omit it and the
* card falls back to the blank `portraitFill` panel.
*/
image?: string;
/**
* object-position for this story's portrait. The default suits a portrait
* whose subject sits at the middle of the frame, 30% down — which is how
* the bundled demo images are cropped.
*/
imagePosition?: string;
}
interface Props {
/** Small uppercase label above the heading. Empty string hides it. */
eyebrow?: string;
/** Section heading. Omit it (or pass "") and there is none. */
heading?: string;
/**
* Five stories reproduce the tested fan; three or seven also work. None,
* and the component renders nothing: it never supplies a story of its own.
*/
stories?: Story[];
/** Milliseconds between automatic advances. 0 turns auto-advance off. */
intervalMs?: number;
/** Pause the timer while the pointer is over the section. */
pauseOnHover?: boolean;
/** Pause the timer while focus is inside the section. */
pauseOnFocus?: boolean;
/** Only run the timer while the section is on screen. */
pauseOffscreen?: boolean;
/** CSS background for the band behind the cards. */
gradient?: string;
/** Fill for the blank portrait placeholder on each card. */
portraitFill?: string;
/** Render the portrait placeholder. Off gives a text-only card. */
showPortrait?: boolean;
/** Heading level, for pages where h2 is wrong. */
headingLevel?: "h1" | "h2" | "h3";
/**
* Small print under the dots. None by default. The catalog demo passes the
* disclaimer that belongs with its fictional stories and stock portraits;
* real stories need none, or whatever note your own content calls for.
*/
footnote?: string;
class?: string;
id?: string;
}
const {
eyebrow = "Customer stories",
heading = "",
stories = [],
intervalMs = 6000,
pauseOnHover = true,
pauseOnFocus = true,
pauseOffscreen = true,
gradient = "linear-gradient(158deg, #3a6ca8 0%, #4b76bb 16%, #6f8fd2 34%, #9a91d6 52%, #c09ce7 68%, #e3b9d8 84%, #f7e9e4 100%)",
portraitFill = "#c3dffe",
showPortrait = true,
headingLevel = "h2",
footnote = "",
class: className = "",
id,
} = Astro.props;
const Heading = headingLevel;
/** Unique within the page, so several instances never share an id. */
const uid = id ?? `sc-${Math.random().toString(36).slice(2, 9)}`;
const key = (story: Story, i: number) => story.id ?? String(i);
---
{/* No stories, nothing to render — not even the heading. */}
{stories.length > 0 && (
<section
class:list={["stories", className]}
id={id}
data-stories
data-uid={uid}
data-interval={String(intervalMs)}
data-pause-hover={pauseOnHover ? "1" : "0"}
data-pause-focus={pauseOnFocus ? "1" : "0"}
data-pause-offscreen={pauseOffscreen ? "1" : "0"}
style={`--sc-gradient:${gradient};--sc-portrait:${portraitFill};`}
>
<div class="stories__band">
<div class="stories__inner" data-scope>
{
eyebrow && (
<p class="stories__eyebrow">
<span>{eyebrow}</span>
</p>
)
}
{heading && <Heading class="stories__title">{heading}</Heading>}
<div class="stories__stage" data-stage>
<div class="stories__track">
{
stories.map((story, i) => (
<article
class="stories__card"
data-card
data-index={i}
data-tier="outer"
aria-roledescription="slide"
aria-label={`${story.stat} — ${story.name}`}
aria-hidden={i === 0 ? "false" : "true"}
tabindex={i === 0 ? 0 : -1}
id={`${uid}-panel-${key(story, i)}`}
>
<div class="stories__content" data-content>
<div class="stories__body" data-body>
<p class="stories__stat">{story.stat}</p>
<p class="stories__statlabel">{story.statLabel}</p>
<blockquote class="stories__quote">
“{story.quote}”
</blockquote>
<figcaption class="stories__by">
<span class="stories__pill">{story.name}</span>
<span class="stories__pill">{story.role}</span>
</figcaption>
</div>
{showPortrait && (
<div class="stories__media" role="presentation" aria-hidden="true">
{story.image && (
<img
class="stories__photo"
src={story.image}
alt=""
loading={i === 0 ? "eager" : "lazy"}
decoding="async"
draggable="false"
style={`--sc-portrait-pos:${story.imagePosition ?? "50% 30%"}`}
/>
)}
</div>
)}
</div>
</article>
))
}
</div>
</div>
<div class="stories__dots" role="tablist" aria-label={eyebrow || "Stories"} data-dots>
{
stories.map((story, i) => (
<button
class="stories__dot"
role="tab"
type="button"
id={`${uid}-tab-${key(story, i)}`}
aria-controls={`${uid}-panel-${key(story, i)}`}
aria-label={`Story ${i + 1}: ${story.name}`}
aria-selected={i === 0 ? "true" : "false"}
tabindex={i === 0 ? 0 : -1}
data-dot
/>
))
}
</div>
{footnote && <p class="stories__footnote">{footnote}</p>}
</div>
</div>
</section>
)}
<style>
/*
* Every value the component needs is declared here, on its own root, so it
* inherits nothing from the host page and leaks nothing back.
*/
.stories {
--sc-ink: #071a31;
--sc-ink-muted: #5f6d77;
--sc-white: #ffffff;
--sc-maxw: 1280px;
--sc-gutter: 16px;
--sc-inset: 8px;
--sc-r-block: 64px;
--sc-r-panel: 48px;
--sc-r-media: 40px;
--sc-font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif;
--sc-font-display: ui-serif, Georgia, "Times New Roman", serif;
--sc-move: 615ms cubic-bezier(0.4, 0, 0.2, 1);
padding-inline: var(--sc-inset);
font-family: var(--sc-font-ui);
font-size: 16px;
line-height: 1.4;
color: var(--sc-ink);
text-align: initial;
}
/* The fixed card sizes assume border-box; the host may not set it. */
.stories *,
.stories *::before,
.stories *::after {
box-sizing: border-box;
}
@media (min-width: 768px) {
.stories {
--sc-gutter: 24px;
--sc-inset: 24px;
}
}
@media (min-width: 1024px) {
.stories {
--sc-gutter: 40px;
}
}
.stories__band {
background: var(--sc-gradient);
border-radius: var(--sc-r-block);
padding-block: 80px 48px;
overflow: hidden; /* the fanned neighbours are clipped by the band */
}
@media (min-width: 768px) {
.stories__band {
padding-block: 96px 64px;
}
}
.stories__inner {
width: 100%;
max-width: var(--sc-maxw);
margin-inline: auto;
padding-inline: var(--sc-gutter);
}
.stories__eyebrow {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
line-height: 1.33;
font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--sc-white);
}
/* The section heading's three type tiers, held flat between breakpoints. */
.stories__title {
margin: 16px auto 0;
max-width: 640px;
text-align: center;
text-wrap: balance;
color: var(--sc-white);
font-weight: 500;
font-size: 36px;
line-height: 39.6px;
}
@media (min-width: 768px) {
.stories__title {
font-size: 48px;
line-height: 52.8px;
letter-spacing: -1px;
}
}
@media (min-width: 1024px) {
.stories__title {
font-size: 60px;
line-height: 66px;
letter-spacing: -2px;
}
}
/* ---------- the fanned stack ---------- */
.stories__stage {
position: relative;
margin-top: 40px;
/* active card height + room for the fan */
height: 560px;
}
.stories__track {
position: absolute;
inset: 0;
}
.stories__card {
position: absolute;
left: 50%;
top: 50%;
background: var(--sc-white);
border-radius: var(--sc-r-panel);
overflow: hidden;
display: grid;
margin: 0;
/* 615ms: the reference settles (>=0.99) at ~543ms, but this bezier reaches
0.99 at ~78% of its nominal duration. */
transition:
width var(--sc-move),
height var(--sc-move),
transform var(--sc-move),
opacity 300ms ease-out;
will-change: transform, width, height;
}
.stories__card[data-tier="active"] {
z-index: 3;
cursor: default;
}
.stories__card[data-tier="inner"],
.stories__card[data-tier="outer"] {
z-index: 2;
cursor: pointer;
}
.stories__card[data-tier="outer"] {
z-index: 1;
}
.stories__card[data-tier="hidden"] {
opacity: 0;
pointer-events: none;
z-index: 0;
}
.stories__card:focus-visible {
outline: 2px solid var(--sc-ink);
outline-offset: 2px;
}
/*
* The content wrapper is display:contents for peeks, so a side card lays
* out exactly as it did before this existed. It only becomes a real box on
* the card in the middle, where it carries the grid — and, while that card
* is growing in, the scale that keeps the contents inside it.
*/
.stories__content {
display: contents;
}
.stories__card[data-tier="active"] .stories__content,
.stories__card[data-leaving] .stories__content {
display: grid;
width: 100%;
height: 100%;
}
/*
* Growing in: the wrapper is pulled out of the card's padding box, given
* the size it will finally be, and scaled down to whatever the card can
* currently hold. Card size and this scale run on the same curve for the
* same 615ms, so the wrapper tracks the card's inner box frame for frame —
* the copy is laid out once at its final metrics and never re-wraps, the
* portrait keeps its exact ratio, and neither ever crosses the card's edge
* to be clipped. Uniform scale only: nothing is squashed on one axis.
*/
.stories__card[data-tier="active"][data-enter] .stories__content {
position: absolute;
left: 50%;
top: 50%;
width: var(--sc-content-w);
height: var(--sc-content-h);
transform: translate(-50%, -50%);
animation: sc-grow 615ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
@keyframes sc-grow {
from {
transform: translate(-50%, -50%) scale(var(--sc-grow-from));
}
to {
transform: translate(-50%, -50%) scale(1);
}
}
/* only the active card shows its text */
.stories__body {
display: none;
}
.stories__card[data-tier="active"] .stories__body,
.stories__card[data-leaving] .stories__body {
display: block;
}
/*
* Content entrance: copy and portrait fade and slide in from the right,
* finishing on the same frame the card's 615ms movement does (470 + 145).
*
* Only opacity and translateX move. Landing with the card means the tail of
* the entrance overlaps the tail of the card's resize, which is only safe
* because the content tracks are pinned to the sizes they resolve to at the
* card's final dimensions (see the tier blocks below). Left as 1fr the copy
* would be laid out against the card's current width and re-wrap in view —
* 63px narrower at desktop when the fade begins, measured.
*
* The 470ms delay is where the card's movement is ~99% done. Earlier than
* that and the pinned content, which exactly fills the card at final size,
* sticks out of a card still too small for it and gets clipped; later and
* there is no room left for a fade.
*
* The marker sits on the CARD, not on either child. The copy is hidden on
* peeks, so its display:none -> block flip would restart keyframes on its
* own; the portrait is displayed the whole time and has no such flip, so
* only a changing attribute above them both can start the two together.
* One shared declaration keeps them to a single duration, curve, distance
* and start time — there is nothing here that could stagger them.
*
* The script sets it only when the active story actually changes, so first
* paint shows its content outright.
*/
@keyframes sc-enter {
from {
opacity: 0;
transform: translateX(32px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.stories__card[data-tier="active"][data-enter] .stories__body,
.stories__card[data-tier="active"][data-enter] .stories__media {
animation: sc-enter 615ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
/*
* Content exit, in two phases over the card's 615ms movement.
*
* Phase 1 (data-leaving, 0-260ms): the copy and the portrait fade out
* together from one shared declaration, so they cannot drift apart. The
* card keeps its active content layout throughout, so neither re-wraps nor
* jumps to the full-bleed peek crop while it is still visible — only the
* card's own white panel is left, shrinking on its way to the side.
*
* Phase 2 (data-arriving, 260-615ms): the layout switch to the peek crop
* happens while the portrait is at zero, so it costs nothing to look at,
* and the photo fades back up — now wearing its white mat — landing on the
* same frame the card reaches its side position.
*/
@keyframes sc-leave {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes sc-arrive {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.stories__card[data-leaving] .stories__body,
.stories__card[data-leaving] .stories__media {
animation: sc-leave 160ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
.stories__card[data-arriving] .stories__media {
animation: sc-arrive 355ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
.stories__media {
aspect-ratio: 3 / 4;
background: var(--sc-portrait);
border-radius: var(--sc-r-media);
width: 100%;
max-width: 100%;
/* clip the photo to the rounded box; also the blank-fill fallback shape */
overflow: hidden;
}
/*
* The portrait box changes shape a lot — 300x433 on the active desktop
* card, 294x242 (landscape) on mobile, and a 105x344 sliver as a peek — so
* the photo covers the box and is anchored on the subject rather than the
* frame. The demo images are cropped with the face at 50% / 30%, which
* keeps it inside every one of those windows.
*/
.stories__photo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: var(--sc-portrait-pos, 50% 30%);
border-radius: inherit;
user-select: none;
}
.stories__card:not([data-tier="active"]):not([data-leaving]) .stories__media {
position: absolute;
inset: 0;
height: 100%;
aspect-ratio: auto;
border-radius: inherit;
}
/*
* Side cards mat their photo in white. The border is drawn inside the
* image's own box — every element here is border-box, so the 8px comes out
* of the picture, not out of the card: peek widths, spacing and positions
* are untouched. The 15px is the mat's own outer radius; the picture inside
* it lands on the 7px the browser derives from 15 minus the 8px border.
*
* Excluded while leaving, because until its fade-out finishes the outgoing
* card is still showing the big active portrait, which has no mat.
*/
.stories__card:not([data-tier="active"]):not([data-leaving]) .stories__photo {
border: 8px solid var(--sc-white);
border-radius: 15px;
}
.stories__stat {
margin: 0;
font-size: 36px;
line-height: 1.1;
font-weight: 500;
letter-spacing: -1px;
}
.stories__statlabel {
margin: 4px 0 0;
font-size: 15px;
color: var(--sc-ink-muted);
}
.stories__quote {
margin: 20px 0 0;
font-family: var(--sc-font-display);
font-size: 18px;
line-height: 1.4;
text-wrap: pretty;
}
.stories__by {
margin: 20px 0 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.stories__pill {
font-size: 12px;
padding: 5px 9px;
border-radius: 8px;
background: rgba(7, 26, 49, 0.05);
color: var(--sc-ink);
}
/*
* Small print: these portraits are stock, not the people quoted. It sits
* over the pale end of the gradient, so it takes the ink colour rather than
* the white the heading uses — white here was too faint to count as a
* label, which is the one thing this line has to be.
*/
.stories__footnote {
margin: 24px auto 0;
max-width: 54ch;
text-align: center;
font-size: 12px;
line-height: 1.5;
color: var(--sc-ink);
opacity: 0.75;
}
/* ---------- dots: 8px tall, active expands to 80px ---------- */
.stories__dots {
display: flex;
justify-content: center;
gap: 6px;
margin-top: 32px;
}
.stories__dot {
position: relative;
width: 8px;
height: 8px;
border: 0;
padding: 0;
font: inherit;
color: inherit;
border-radius: 3px;
background: rgba(252, 251, 248, 0.5);
cursor: pointer;
transition:
width 300ms cubic-bezier(0.4, 0, 0.2, 1),
background-color 300ms ease;
}
/* the dot stays 8px. The hit area gains the full 44px vertically and spans
the 6px gap horizontally, so adjacent targets tile rather than overlap —
44px-wide targets on 14px centres would steal each other's taps. */
.stories__dot::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: calc(100% + 6px);
height: 44px;
transform: translate(-50%, -50%);
}
.stories__dot:hover {
background: rgba(252, 251, 248, 0.8);
}
.stories__dot[aria-selected="true"] {
width: 80px;
background: var(--sc-white);
}
.stories__dot:focus-visible {
outline: 2px solid var(--sc-white);
outline-offset: 3px;
}
/* ---------- mobile: active 342x538 ---------- */
.stories__card[data-tier="active"] {
width: 342px;
height: 538px;
transform: translate(-50%, -50%);
}
/*
* Content layout, shared with the outgoing card: while it fades it keeps
* the geometry it was laid out with, instead of re-wrapping into whatever
* the shrinking card would give it. The card's own box stays above, so the
* card still travels to its peek size normally.
*/
.stories__card[data-tier="active"],
.stories__card[data-leaving] {
/* final inner box: 342 - 48, 538 - 48. Start scale is what the peek can
hold: (105 - 48) / 294 */
--sc-content-w: 294px;
--sc-content-h: 490px;
--sc-grow-from: 0.1939;
padding: 24px;
/* the portrait takes the remainder of the fixed-height card; without this
its 3/4 ratio resolves to 392px and is clipped by the card. Both rows
are pinned to what they resolve to at the card's final height
(538 - 2x24 padding - 16 gap = 490 = 232 + 242), so the portrait keeps
its height while the card is still growing instead of stretching into
place. Consistent with the rest of the card, which is fixed-size by
design; a much longer quote would overflow rather than squeeze the
portrait. */
}
/*
* Tracks are pinned to the width they resolve to at the card's final size,
* so the copy is laid out once and never re-wraps; the wrapper's scale is
* what makes it fit a card that is still growing.
*/
.stories__card[data-tier="active"] .stories__content,
.stories__card[data-leaving] .stories__content {
grid-template-rows: 232px 242px;
grid-template-columns: 294px;
justify-content: center;
gap: 16px;
}
.stories__card[data-tier="active"] .stories__media,
.stories__card[data-leaving] .stories__media {
width: 100%;
height: 100%;
aspect-ratio: auto;
align-self: stretch;
}
.stories__card[data-tier="inner"] {
width: 105px;
height: 344px;
transform: translate(calc(-50% + var(--sc-fan-inner, 228px)), -50%);
}
.stories__card[data-tier="inner"][data-side="left"] {
transform: translate(calc(-50% - var(--sc-fan-inner, 228px)), -50%);
}
/* outer tier is not rendered at mobile */
.stories__card[data-tier="outer"] {
opacity: 0;
pointer-events: none;
}
@media (min-width: 768px) {
.stories__stage {
height: 470px;
}
.stories__card[data-tier="active"] {
width: 672px;
height: 430px;
}
.stories__card[data-tier="active"],
.stories__card[data-leaving] {
/* 672 - 64, 430 - 64; start scale (105 - 64) / 608 */
--sc-content-w: 608px;
--sc-content-h: 366px;
--sc-grow-from: 0.0674;
padding: 32px;
text-align: left;
}
/* two columns from 768 up: a single column leaves the portrait
unconstrained, and it resolves to 608x811 inside a 430px card.
Pinned to their final widths: 672 - 2x32 - 24 gap - 240 = 344 */
.stories__card[data-tier="active"] .stories__content,
.stories__card[data-leaving] .stories__content {
grid-template-columns: 344px 240px;
justify-content: center;
grid-template-rows: none;
gap: 24px;
align-items: center;
}
.stories__card[data-tier="active"] .stories__media,
.stories__card[data-leaving] .stories__media {
height: 366px;
width: 240px;
align-self: center;
}
.stories__card[data-tier="inner"] {
/* the band is only 705px wide at this step against a 672px card, so the
neighbours tuck behind it and show as slivers at the band edges */
--sc-fan-inner: 350px;
}
.stories__card[data-tier="outer"] {
opacity: 1;
pointer-events: auto;
width: 74px;
height: 205px;
--sc-fan-outer: 430px;
transform: translate(calc(-50% + var(--sc-fan-outer, 535px)), -50%);
}
.stories__card[data-tier="outer"][data-side="left"] {
transform: translate(calc(-50% - var(--sc-fan-outer, 535px)), -50%);
}
}
@media (min-width: 1024px) {
.stories__band {
padding-block: 152px 120px;
}
.stories__stage {
height: 560px;
margin-top: 56px;
}
.stories__card[data-tier="active"] {
width: 762px;
height: 513px;
border-radius: var(--sc-r-block);
}
/*
* The portrait is a true 9:16 panel held 10px off the card's top, bottom
* and right edge, so the card's padding is that 10px and the copy pays
* back its own inset instead. No gap: the copy column runs right up to
* the portrait and takes whatever the portrait leaves.
*
* height 513 - 10 - 10 = 493
* width 493 x 9 / 16 = 277.3125
* copy 762 - 20 - 277.3125 = 464.6875
*
* Both tracks stay pinned in px so the copy cannot re-wrap while the
* card is still resizing, which is what the entrance and exit rely on.
*/
.stories__card[data-tier="active"],
.stories__card[data-leaving] {
--sc-portrait-h: calc(513px - 20px);
--sc-portrait-w: calc(var(--sc-portrait-h) * 9 / 16);
/* 762 - 20, 513 - 20; start scale (105 - 20) / 742 */
--sc-content-w: calc(762px - 20px);
--sc-content-h: var(--sc-portrait-h);
--sc-grow-from: 0.1146;
padding: 10px;
text-align: left;
}
.stories__card[data-tier="active"] .stories__content,
.stories__card[data-leaving] .stories__content {
grid-template-columns:
calc(762px - 20px - var(--sc-portrait-w))
var(--sc-portrait-w);
justify-content: center;
grid-template-rows: none;
gap: 0;
align-items: center;
}
/* 40px from the card edge for the text, as before: 10 card + 30 here */
.stories__card[data-tier="active"] .stories__body,
.stories__card[data-leaving] .stories__body {
padding-left: 30px;
padding-right: 24px;
}
.stories__card[data-tier="active"] .stories__media,
.stories__card[data-leaving] .stories__media {
height: var(--sc-portrait-h);
width: var(--sc-portrait-w);
align-self: center;
/*
* Nested concentrically inside the card's 64px corner at a 10px
* gap. At the old 40px the portrait's two right-hand corners sat
* outside the card's curve and were clipped by it.
*/
border-radius: calc(var(--sc-r-block) - 10px);
}
.stories__card[data-tier="inner"] {
--sc-fan-inner: 454px;
}
.stories__card[data-tier="outer"] {
--sc-fan-outer: 559px;
}
}
@media (prefers-reduced-motion: reduce) {
.stories__card,
.stories__dot {
transition: none;
}
.stories__card[data-tier="active"][data-enter] .stories__body,
.stories__card[data-tier="active"][data-enter] .stories__media,
.stories__card[data-leaving] .stories__body,
.stories__card[data-leaving] .stories__media,
.stories__card[data-arriving] .stories__media,
.stories__card[data-tier="active"][data-enter] .stories__content {
animation: none;
}
}
</style>
<script>
/*
* One setup pass per instance on the page. Every piece of state lives in
* the closure below, so two carousels never share a selection or a timer.
*/
const setup = (root: HTMLElement) => {
const stage = root.querySelector<HTMLElement>("[data-stage]");
const dotBar = root.querySelector<HTMLElement>("[data-dots]");
const scope = root.querySelector<HTMLElement>("[data-scope]") ?? root;
if (!stage || !dotBar) return;
const cards = [...stage.querySelectorAll<HTMLElement>("[data-card]")];
const dots = [...dotBar.querySelectorAll<HTMLButtonElement>("[data-dot]")];
const n = cards.length;
if (n === 0 || dots.length !== n) return;
/* must match the card transition and the exit phases in the stylesheet */
const MOVE_MS = 615;
const LEAVE_MS = 260;
const interval = Number(root.dataset.interval ?? "6000");
const pauseHover = root.dataset.pauseHover === "1";
const pauseFocus = root.dataset.pauseFocus === "1";
const pauseOffscreen = root.dataset.pauseOffscreen === "1";
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
let index = 0;
let presented: HTMLElement | null = null;
const exitTimers = new Map<HTMLElement, number[]>();
let timer: number | undefined;
let hovering = false;
let focused = false;
let onScreen = !pauseOffscreen;
/* Drop any exit a card is in the middle of, attributes and timers both.
Reversing direction or clicking through quickly lands here, and a card
that is coming back to the centre must not still be fading out. */
const clearExit = (card: HTMLElement) => {
const t = exitTimers.get(card);
if (t) {
for (const id of t) clearTimeout(id);
exitTimers.delete(card);
}
card.removeAttribute("data-leaving");
card.removeAttribute("data-arriving");
};
/* Fade the outgoing copy and portrait out together, then bring the
portrait back as a peek once the crop has changed behind the fade. */
const beginExit = (card: HTMLElement) => {
clearExit(card);
if (reduced.matches) return;
card.setAttribute("data-leaving", "");
exitTimers.set(card, [
window.setTimeout(() => {
card.removeAttribute("data-leaving");
card.setAttribute("data-arriving", "");
}, LEAVE_MS),
window.setTimeout(() => {
card.removeAttribute("data-arriving");
exitTimers.delete(card);
}, MOVE_MS),
]);
};
/* assign each card a tier by its circular distance from the active one,
so the stack stays symmetric however far the selection jumps */
const render = (enter = false) => {
cards.forEach((card, i) => {
let d = (i - index + n) % n;
if (d > n / 2) d -= n; // -2..2 for five cards
const side = d < 0 ? "left" : "right";
const tier = d === 0 ? "active" : Math.abs(d) === 1 ? "inner" : "outer";
card.dataset.tier = tier;
card.dataset.side = side;
card.setAttribute("aria-hidden", tier === "active" ? "false" : "true");
card.tabIndex = tier === "active" ? 0 : -1;
/* Arm the entrance on the card itself, so the copy and the portrait
inside it start from one attribute change and cannot drift apart.
Leaving it off for the first render is what keeps first paint still. */
if (tier === "active" && enter) card.setAttribute("data-enter", "");
else if (tier !== "active") card.removeAttribute("data-enter");
});
const next = cards[index];
/* whatever state the incoming card was in, it is the centre now */
clearExit(next);
if (enter && presented && presented !== next) beginExit(presented);
presented = next;
dots.forEach((dot, i) => {
const on = i === index;
dot.setAttribute("aria-selected", String(on));
dot.tabIndex = on ? 0 : -1;
});
};
const select = (i: number, moveFocus = false) => {
const next = (i + n) % n;
/* re-selecting the story already showing must not replay its entrance */
const changed = next !== index;
index = next;
render(changed);
if (moveFocus) dots[index].focus();
};
const canRun = () =>
interval > 0 &&
!reduced.matches &&
onScreen &&
!(pauseHover && hovering) &&
!(pauseFocus && focused);
const stop = () => {
/* explicit undefined check: a falsy handle must still be cleared, or a
second interval can stack on the first and advance two at a time */
if (timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
};
const start = () => {
stop();
if (!canRun()) return;
timer = window.setInterval(() => select(index + 1), interval);
};
const refresh = () => (canRun() ? start() : stop());
/* only advance while the section is on screen */
if (pauseOffscreen && "IntersectionObserver" in window) {
new IntersectionObserver(
(entries) => {
onScreen = entries.some((e) => e.isIntersecting);
refresh();
},
{ threshold: 0.25 },
).observe(stage);
} else {
onScreen = true;
}
scope.addEventListener("pointerenter", () => {
hovering = true;
refresh();
});
scope.addEventListener("pointerleave", () => {
hovering = false;
refresh();
});
scope.addEventListener("focusin", () => {
focused = true;
refresh();
});
scope.addEventListener("focusout", (e) => {
if (!scope.contains(e.relatedTarget as Node)) {
focused = false;
refresh();
}
});
/* clicking a peek card brings it to the front */
cards.forEach((card, i) => {
card.addEventListener("click", () => {
if (card.dataset.tier === "active") return;
select(i);
refresh();
});
});
dots.forEach((dot, i) => {
dot.addEventListener("click", () => {
select(i);
refresh();
});
dot.addEventListener("keydown", (e) => {
let next: number | null = null;
if (e.key === "ArrowRight") next = index + 1;
else if (e.key === "ArrowLeft") next = index - 1;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = n - 1;
if (next === null) return;
e.preventDefault();
select(next, true);
});
});
render();
refresh();
reduced.addEventListener("change", refresh);
};
document.querySelectorAll<HTMLElement>("[data-stories]").forEach(setup);
</script>
===== END src/components/StoryCarousel.astro =====
===== FILE (supporting): src/components/StoryCarousel.md =====
# Story carousel
A fanned stack of customer-story cards on a gradient band. Five cards sit
symmetric about centre in three size tiers; the active card resizes as cards
move between tiers, and stacking order participates. Dot controls, a timed
advance, full keyboard operation, and reduced-motion support.
Catalog page: `/components/story-carousel`
## Copying it into another Astro project
Copy **`StoryCarousel.astro`** — one file, still the whole component.
- No imports — not from this catalog, not from anywhere.
- No companion `.astro` files, no npm packages, no Astro built-ins.
- No global stylesheet. Every colour, radius, font stack and breakpoint value
it needs is declared on its own root element as a `--sc-*` custom property,
so it inherits nothing from the host page and leaks nothing back.
- No webfonts and no network requests beyond the portraits below.
- The only assumption about the host is that Astro compiles `.astro` files.
### The demo portraits
The component has **no stories of its own**: pass none and it renders nothing
at all. The catalog demo passes five stories (from the registry's demo
`props`) that reference bundled images by URL:
```
public/story-carousel/portrait-1.webp … portrait-5.webp
```
Nothing in the component's source references them, so they are only needed to
reproduce the catalog demo. For your own stories, every `image` is optional —
drop it and that card falls back to the blank `portraitFill` panel:
```astro
---
import StoryCarousel from "../components/StoryCarousel.astro";
const stories = [
{ stat: "9 hours", statLabel: "back per person", quote: "…",
name: "Priya Raman", role: "Operations Lead",
image: "/my/own/portrait.jpg" }, // omit for a blank fill
];
---
<StoryCarousel stories={stories} heading="What our clients say" />
```
Mixing works: stories with an `image` show a photo, stories without show the
blank fill, in the same carousel.
The component paints its own band but not the page behind it. It was designed
against an off-white page (`#fcfbf8`); on pure white the band's rounded corners
read slightly harder.
## Image credits and licence
The stories, names, companies and figures in this demo are **fictional**, and
the portraits are **stock photography**. The people pictured are models — they
are not the people quoted, they said none of it, and their presence is not an
endorsement of anything. The catalog demo says so in a `footnote` it passes
explicitly, which renders under the dots. The component itself has no footnote
by default — that disclaimer belongs to the demo content, not to real stories.
All five are from Pexels under the [Pexels licence](https://www.pexels.com/license/)
— free to use commercially and non-commercially, no attribution required. It is
recorded here anyway, because knowing where a file came from is worth more than
the licence strictly demands.
| File | Photographer | Source page |
| :--- | :--- | :--- |
| `portrait-1.webp` | Finn Gruber | [Portrait of Smiling Woman in Business Attire](https://www.pexels.com/photo/portrait-of-smiling-woman-in-business-attire-31869537/) |
| `portrait-2.webp` | Joseph Eulo | [Man Wearing Black Suit on White Background](https://www.pexels.com/photo/man-wearing-black-suit-on-white-background-12311572/) |
| `portrait-3.webp` | Biola Visuals | [Confident Woman in Black Outfit Portrait](https://www.pexels.com/photo/confident-woman-in-black-outfit-portrait-30198184/) |
| `portrait-4.webp` | Wasin Pirom | [Professional Portrait of a Businessman in Suit](https://www.pexels.com/photo/professional-portrait-of-a-businessman-in-suit-31880922/) |
| `portrait-5.webp` | Tiago Chaves | [Smiling Woman Portrait Against Gray Background](https://www.pexels.com/photo/smiling-woman-portrait-against-gray-background-36439572/) |
Each was downloaded from Pexels, cropped to 2:3 with the subject's face placed
at 50% across and 30% down, resized to 600×900 and encoded as WebP (~24–67 KB
each, ~178 KB for all five). That common face position is what lets one
`object-position` serve every crop the layout asks for — the 300×433 portrait
on the active desktop card, the 294×242 landscape box on mobile, and the
105×344 and 74×205 slivers of the side peeks.
One thing the Pexels licence asks of you directly: do not use images of
identifiable people in a way that implies endorsement. A fabricated testimonial
beside a real face is exactly that risk, which is why the demo carries its
disclaimer on the page rather than only in this file. Reuse these portraits
beside real quotes and you take on that risk; use photographs of the people
actually quoted.
## Props
All optional. The defaults carry no content: the catalog demo passes its
stories, heading and footnote explicitly.
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `stories` | `Story[]` | `[]` | The cards. Five gives the tested fan; three or seven also work. **None, and nothing renders at all.** |
| `eyebrow` | `string` | `"Customer stories"` | Small uppercase label. `""` hides it. |
| `heading` | `string` | `""` | Section heading. Omitted, there is none. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is the wrong level. |
| `intervalMs` | `number` | `6000` | Milliseconds between advances. `0` turns the timer off. |
| `pauseOnHover` | `boolean` | `true` | Hold while the pointer is over the section. |
| `pauseOnFocus` | `boolean` | `true` | Hold while focus is inside the section. |
| `pauseOffscreen` | `boolean` | `true` | Only run the timer while the section is on screen. |
| `gradient` | `string` | blue → pink | Any CSS `background` value for the band. |
| `portraitFill` | `string` | `#c3dffe` | Fill shown when a story has no `image`. |
| `footnote` | `string` | `""` | Small print under the dots. Omitted, there is none. |
| `showPortrait` | `boolean` | `true` | Off gives a text-only card. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | auto | Root element id. Also seeds the internal element ids. |
### `Story`
```ts
interface Story {
id?: string; // used for element ids; falls back to position
stat: string; // "9 hours"
statLabel: string; // "back per person, every month"
quote: string; // curly quotes are added around it
name: string;
role: string;
image?: string; // portrait URL; omit for the blank fill
imagePosition?: string; // object-position, default "50% 30%"
}
```
## Geometry
| | ≥1024 | ≥768 | <768 |
| :--- | :--- | :--- | :--- |
| Active card | 762 × 513 | 672 × 430 | 342 × 538 |
| Inner peek ×2 | 105 × 344 at ±454 | 105 × 344 at ±350 | 105 × 344 at ±228 |
| Outer peek ×2 | 74 × 205 at ±559 | 74 × 205 at ±430 | not rendered |
| Portrait | 300 × 433 | 240 × 366 | 294 × 242 |
| Band padding | 152 / 120 | 96 / 64 | 80 / 48 |
Card movement is `615ms cubic-bezier(0.4, 0, 0.2, 1)`. Dots are 8px tall, 8px
wide inactive, 80px active, over `300ms` on the same curve.
When a story becomes active, its copy and its portrait fade and slide in from
the right **together** — one shared declaration, so the same `145ms`, the same
curve, the same 32px distance, no stagger. `opacity` and `translateX` only.
The entrance runs `470ms → 615ms`, so it lands on the same frame the card's own
movement ends. Overlapping the card's resize that way is only safe because the
active card's grid tracks are pinned to the widths they resolve to at its final
size (`358px 300px` / `344px 240px` / `294px`, and `232px 242px` rows on
mobile). Left as `1fr` the copy would be laid out against the card's current
width and visibly re-wrap as it fades in. Pinned, both areas hold their final
size from the first visible frame — nothing skinny, stretched, clipped or
reflowing. Final geometry is identical either way.
## Behaviour
- **Advance:** click a dot, click a peek card, or wait for the timer.
- **Keyboard:** with a dot focused, `ArrowLeft` / `ArrowRight` move selection
and focus and wrap at both ends; `Home` and `End` jump to first and last.
The tablist uses roving `tabindex`, so it is one tab stop.
- **Touch targets:** each dot's hit area is 44px tall and spans the 6px gap
between dots, so adjacent targets tile rather than overlap.
- **Entrance:** copy and portrait together, on dot clicks, peek clicks,
keyboard navigation and timed advances. It does not play on first paint, and
re-selecting the story already showing does not replay it.
- **Reduced motion:** `prefers-reduced-motion: reduce` turns off the timer, all
transitions and the content entrance. Manual selection still works, and
content appears immediately.
- **Several per page:** each instance keeps its own selection, timer, dots,
hover and focus state, and generates its own element ids.
## Notes
The demo's stories, names, companies and figures are fictional, and no logos
or real testimonials are included. They live in the catalog registry's demo
`props`, not in the component. The portraits are not blank fills: five stock
photographs ship with the demo and are credited in full under **Image credits
and licence** above. The blank `portraitFill` panel is the fallback for a
story with no `image`, not what the demo renders.
===== END src/components/StoryCarousel.md =====
===== FILE (required): public/story-carousel/portrait-1.webp =====
[binary asset — download https://astro.baysixmedia.com/story-carousel/portrait-1.webp and save it to public/story-carousel/portrait-1.webp]
===== FILE (required): public/story-carousel/portrait-2.webp =====
[binary asset — download https://astro.baysixmedia.com/story-carousel/portrait-2.webp and save it to public/story-carousel/portrait-2.webp]
===== FILE (required): public/story-carousel/portrait-3.webp =====
[binary asset — download https://astro.baysixmedia.com/story-carousel/portrait-3.webp and save it to public/story-carousel/portrait-3.webp]
===== FILE (required): public/story-carousel/portrait-4.webp =====
[binary asset — download https://astro.baysixmedia.com/story-carousel/portrait-4.webp and save it to public/story-carousel/portrait-4.webp]
===== FILE (required): public/story-carousel/portrait-5.webp =====
[binary asset — download https://astro.baysixmedia.com/story-carousel/portrait-5.webp and save it to public/story-carousel/portrait-5.webp]
--- VERIFICATION ---
This component is not enrolled in the library's verification system, so there is no
recorded evidence about it either way. Treat it as unverified and test it yourself.
--- TEST IN THIS PROJECT AFTER INSTALLING ---
- 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.