Component librarylocal catalog
Back to catalog

Story carousel

A fanned stack of customer-story cards on a gradient band, with dot controls and a timed advance.

sectioncarouseltestimonial

Open preview in a new tab

Use this component

Three ways to get Story 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
“Story carousel” from my Astro Component Library:
https://astro.baysixmedia.com/components/story-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: Story carousel
Library page: https://astro.baysixmedia.com/components/story-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 Story carousel from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/story-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.

src/components/library/StoryCarousel.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/story-carousel/portrait-1.webp, public/story-carousel/portrait-2.webp, public/story-carousel/portrait-3.webp, public/story-carousel/portrait-4.webp, public/story-carousel/portrait-5.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * 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. Empty string hides it. */
	heading?: string;
	/** Five stories reproduce the tested fan; three or seven also work. */
	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. Defaults to the disclaimer that belongs with
	 * the bundled demo portraits; pass "" to remove it, or your own text.
	 */
	footnote?: string;
	class?: string;
	id?: string;
}

const defaultStories: Story[] = [
	{
		id: "harbor",
		image: "/story-carousel/portrait-1.webp",
		stat: "9 hours",
		statLabel: "back per person, every month",
		quote:
			"We stopped running a scheduling process. The meetings just happen now, and the notes are there before I have closed the laptop.",
		name: "Priya Raman",
		role: "Operations Lead, Harbor & Fern",
	},
	{
		id: "tolliver",
		image: "/story-carousel/portrait-2.webp",
		stat: "0 no-shows",
		statLabel: "across a full quarter of consultations",
		quote:
			"Asking for a deposit felt risky until we tried it. It turns out people who pay fifty pounds up front actually turn up.",
		name: "Dan Achebe",
		role: "Founder, Tolliver Practice",
	},
	{
		id: "meridian",
		image: "/story-carousel/portrait-3.webp",
		stat: "62% fewer",
		statLabel: "emails spent arranging interviews",
		quote:
			"Coordinating four interviewers used to be someone’s afternoon. The thread runs itself and I read the outcome.",
		name: "Sofia Lindqvist",
		role: "Head of Talent, Meridian Labs",
	},
	{
		id: "brightwater",
		image: "/story-carousel/portrait-4.webp",
		stat: "3 days",
		statLabel: "faster from first call to signed invoice",
		quote:
			"The invoice goes out attached to the recap while the conversation is still fresh. We get paid in the same week now.",
		name: "Marcus Bell",
		role: "Principal, Brightwater Advisory",
	},
	{
		id: "kestrel",
		image: "/story-carousel/portrait-5.webp",
		stat: "4 hours",
		statLabel: "of admin removed from every week",
		quote:
			"I used to keep a notebook of what each client said. Now I search one place and the answer is there with a date on it.",
		name: "Ana Ferreira",
		role: "Coach, Kestrel Studio",
	},
];

const {
	eyebrow = "Customer stories",
	heading = "Real teams. Fewer tabs.",
	stories = defaultStories,
	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 = "Stories and names are fictional. Portraits are illustrative stock photography — the people pictured are not the people quoted, and nothing here is an endorsement.",
	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);
---

<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">
										&ldquo;{story.quote}&rdquo;
									</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>