Component library
Back to catalog

Staggered logo cloud

A centred "trusted by" row whose whole set of logos is replaced on a timer, each one blurring out to the right as its replacement arrives from the left a beat later.

sectionlogossocial proofmarqueeanimationstagger

Open preview in a new tab

Use this component

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

Start here

Placement prompt

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

Read it first — the files are added underneath when you copy
Add Staggered logo cloud from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/staggered-logo-cloud/

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

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

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Staggered logo cloud"
Library page: https://astro.baysixmedia.com/components/staggered-logo-cloud/
Files: 1 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/StaggeredLogoCloud.astro =====
---
/**
 * StaggeredLogoCloud — a "trusted by" logo row whose whole set is replaced on a
 * timer, each logo leaving to the right and arriving from the left a beat after
 * the one before it.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * 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 Tailark "Trusted by people from"
 * section (https://pro.tailark.com/), from the measurements recorded in
 * StaggeredLogoCloud.md. No source, markup, CSS, script, branding, logo or
 * artwork from that site is used here, and the demo wordmarks are original.
 *
 * The moving parts, and why they are shaped this way:
 *
 *   - Every group is a grid layer stacked in the same `grid-area`, so the eight
 *     cells an outgoing logo leaves are the exact cells its replacement arrives
 *     in. Nothing is measured at runtime and nothing reflows mid-transition.
 *   - Stagger is one custom property per cell (`--slc-i`) feeding a
 *     `transition-delay`. No per-frame scripting, no animation library.
 *   - Groups are normalised to a common cell count, so a group of six and a
 *     group of eight occupy identical geometry and a short group cannot make
 *     the page jump when it takes its turn.
 *   - The wrap from the last group to the first is `(i + 1) % count` and
 *     nothing else. There is no branch at the boundary, so there is nothing
 *     that could look different there.
 *
 * 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 LogoCloudItem {
	/** Stable identity. Used for the cell's key and for the rendered id. */
	id: string;
	/** The company or product name. Also the accessible name, and the wordmark. */
	name: string;
	/** Image URL. Omit to render a text wordmark instead. */
	src?: string;
	/** Alt text. Defaults to the name; an empty string marks it decorative. */
	alt?: string;
	/** Optional destination. An unsafe value renders as inert, non-linked text. */
	href?: string;
	/** Opens in a new tab with the matching rel. */
	external?: boolean;
	/** Intrinsic width, so the browser can reserve the right box. */
	width?: number;
	/** Intrinsic height, so the browser can reserve the right box. */
	height?: number;
	/** `invert` flips a dark-on-light image under a dark colour scheme. */
	darkMode?: "none" | "invert";
	/**
	 * Treatment for the text wordmark drawn when there is no `src`.
	 *
	 * Not in the reference, which ships real logos. It exists so a library
	 * preview can show eight visually distinct marks without copying anybody's
	 * artwork, and so a destination project has something reasonable to render
	 * before its own assets arrive.
	 */
	wordmark?: "plain" | "bold" | "wide" | "dot" | "slash" | "box";
}

export interface LogoCloudGroup {
	/** Stable identity. Used for the group's rendered id. */
	id: string;
	logos: LogoCloudItem[];
}

export interface StaggeredLogoCloudProps {
	/** The sets of logos to cycle through. */
	groups: LogoCloudGroup[];
	/** The muted line above the logos, and the region's accessible name. */
	label?: string;
	/** Advance through the groups on a timer. */
	autoplay?: boolean;
	/** Milliseconds each group is held before the next one arrives. */
	interval?: number;
	/** Hold the timer while the pointer is over the component. */
	pauseOnHover?: boolean;
	/** Render the play/pause control. Only ever shown when autoplay can run. */
	showPauseControl?: boolean;
	/** Accessible name for the control while it would pause. */
	pauseLabel?: string;
	/** Accessible name for the control while it would resume. */
	playLabel?: string;
	class?: string;
	id?: string;
}

interface Props extends StaggeredLogoCloudProps {}

const {
	groups,
	label = "Trusted by people from",
	autoplay = true,
	interval = 4000,
	pauseOnHover = true,
	showPauseControl = true,
	pauseLabel = "Pause logo rotation",
	playLabel = "Resume logo rotation",
	class: className,
	id,
}: Props = Astro.props;

/*
 * Item data is treated as untrusted: it typically comes from a CMS or a
 * content collection, and an 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;

/** Control characters are stripped first: "java\tscript:" is a scheme too. */
function stripControls(value: string): string {
	return [...value]
		.filter((ch) => {
			const code = ch.codePointAt(0) ?? 0;
			return code > 31 && code !== 127;
		})
		.join("");
}

function safeHref(value: unknown): string | undefined {
	if (typeof value !== "string") return undefined;
	const raw = value.trim();
	if (raw === "") return undefined;
	const bare = stripControls(raw).trim();
	if (bare === "") return undefined;
	if (
		bare.startsWith("/") ||
		bare.startsWith("#") ||
		bare.startsWith("./") ||
		bare.startsWith("../")
	) {
		return bare;
	}
	if (SAFE_SCHEME.test(bare)) return bare;
	/* A bare relative path has no scheme before its first slash. */
	const colon = bare.indexOf(":");
	const slash = bare.indexOf("/");
	if (colon === -1 || (slash !== -1 && slash < colon)) return bare;
	return undefined;
}

/**
 * Image sources are checked more narrowly than links.
 *
 * `data:` is allowed here and refused for hrefs, because an inline SVG is a
 * legitimate image and a legitimate navigation target is never a data URL.
 */
function safeSrc(value: unknown): string | undefined {
	if (typeof value !== "string") return undefined;
	const raw = value.trim();
	if (raw === "") return undefined;
	const bare = stripControls(raw);
	if (/^\s*(javascript|vbscript):/i.test(bare)) return undefined;
	return raw;
}

const WORDMARKS = new Set(["plain", "bold", "wide", "dot", "slash", "box"]);

interface ResolvedLogo {
	key: string;
	name: string;
	src?: string;
	alt: string;
	href?: string;
	external: boolean;
	width?: number;
	height?: number;
	invert: boolean;
	wordmark: string;
}

const seenGroupIds = new Set<string>();

/*
 * Groups with nothing usable in them are dropped rather than rendered as empty
 * rows. A set of no logos is not an empty set of logos — it is a data mistake,
 * and showing a blank beat in the rotation would make it look deliberate.
 */
const resolvedGroups = (Array.isArray(groups) ? groups : [])
	.map((group) => {
		if (!group || typeof group.id !== "string" || group.id.trim() === "") return null;
		const logos: ResolvedLogo[] = (Array.isArray(group.logos) ? group.logos : [])
			.filter(
				(logo): logo is LogoCloudItem =>
					Boolean(logo) && typeof logo.id === "string" && typeof logo.name === "string",
			)
			.filter((logo) => logo.id.trim() !== "" && logo.name.trim() !== "")
			.map((logo) => {
				const href = safeHref(logo.href);
				const wordmark =
					typeof logo.wordmark === "string" && WORDMARKS.has(logo.wordmark)
						? logo.wordmark
						: "plain";
				return {
					key: logo.id.trim(),
					name: logo.name.trim(),
					src: safeSrc(logo.src),
					alt: typeof logo.alt === "string" ? logo.alt : logo.name.trim(),
					href,
					external: href !== undefined && logo.external === true,
					width: Number.isFinite(logo.width) ? Number(logo.width) : undefined,
					height: Number.isFinite(logo.height) ? Number(logo.height) : undefined,
					invert: logo.darkMode === "invert",
					wordmark,
				};
			});
		if (logos.length === 0) return null;
		const groupId = group.id.trim();
		if (seenGroupIds.has(groupId)) return null;
		seenGroupIds.add(groupId);
		return { key: groupId, logos };
	})
	.filter((group): group is { key: string; logos: ResolvedLogo[] } => group !== null);

/*
 * One cell count for every group, taken from the largest.
 *
 * A group with fewer logos is padded with empty cells rather than laid out on
 * its own terms. Identical geometry is what lets the stage keep its height and
 * lets an outgoing logo hand its exact cell to the one replacing it.
 */
const cellCount = resolvedGroups.reduce((max, group) => Math.max(max, group.logos.length), 0);

const groupCount = resolvedGroups.length;
/* One group is a logo row, not a rotation: no timer, no control, no layers. */
const cycles = groupCount > 1;

/* Floored so a mistyped interval cannot produce a strobe. */
const safeInterval = Math.max(1500, Number(interval) || 4000);

/*
 * 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 ?? `slc-${Math.random().toString(36).slice(2, 9)}`;
const stageId = `${uid}-stage`;
---

{
	groupCount > 0 && (
		<section
			class:list={["slc", className]}
			id={id}
			style={`--slc-cols:${cellCount}`}
			data-slc-root
			data-slc-cycles={cycles ? "1" : "0"}
			data-slc-autoplay={autoplay ? "1" : "0"}
			data-slc-interval={String(safeInterval)}
			data-slc-pause-hover={pauseOnHover ? "1" : "0"}
			data-slc-control={showPauseControl ? "1" : "0"}
			data-slc-pause-label={pauseLabel}
			data-slc-play-label={playLabel}
			aria-label={label}
		>
			<div class="slc-inner">
				{/*
				    Three columns rather than a centred flex row: the label stays
				    optically centred in the section whether or not the control is
				    rendered, and the control can never overlap it at any width.
				*/}
				<div class="slc-head">
					<span class="slc-spacer" aria-hidden="true" />
					<p class="slc-label">{label}</p>
					<div class="slc-controls">
						{cycles && autoplay && showPauseControl && (
							<button
								class="slc-toggle"
								type="button"
								data-slc-toggle
								aria-controls={stageId}
								aria-pressed="false"
								hidden
							>
								<span class="slc-sr" data-slc-toggle-label>
									{pauseLabel}
								</span>
								<span class="slc-glyph slc-glyph-pause" aria-hidden="true" />
								<span class="slc-glyph slc-glyph-play" aria-hidden="true" />
							</button>
						)}
					</div>
				</div>

				{/*
				    Every group is a layer in one grid cell of this stage, so the
				    stage is as tall as a group and stays that height while two of
				    them overlap.
				*/}
				<div class="slc-stage" id={stageId} data-slc-stage>
					{resolvedGroups.map((group, groupIndex) => (
						<ul
							class="slc-grid"
							data-slc-group
							data-slc-group-index={String(groupIndex)}
							data-slc-state={groupIndex === 0 ? "active" : "idle"}
							aria-hidden={groupIndex === 0 ? undefined : "true"}
						>
							{Array.from({ length: cellCount }).map((_, cellIndex) => {
								const logo = group.logos[cellIndex];
								if (!logo) {
									/* A padding cell. It holds the column open so a short
									   group cannot change the geometry when its turn comes. */
									return <li class="slc-cell" style={`--slc-i:${cellIndex}`} aria-hidden="true" />;
								}
								/* One wrapper, whose tag depends on whether the item ended up
								   with a usable href. Rendering the media twice — once inside
								   a link and once outside it — is how the two copies drift. */
								const Item = logo.href ? "a" : "span";
								return (
									<li class="slc-cell" style={`--slc-i:${cellIndex}`}>
										<span class="slc-logo">
											<Item
												class="slc-item"
												href={logo.href}
												target={logo.external ? "_blank" : undefined}
												rel={logo.external ? "noopener noreferrer" : undefined}
												tabindex={logo.href && groupIndex !== 0 ? "-1" : undefined}
											>
												{logo.src ? (
													<img
														class:list={["slc-img", logo.invert && "is-invertible"]}
														src={logo.src}
														alt={logo.alt}
														width={logo.width}
														height={logo.height}
														loading="lazy"
														decoding="async"
														draggable="false"
													/>
												) : (
													<span class:list={["slc-mark", `is-${logo.wordmark}`]}>{logo.name}</span>
												)}
											</Item>
										</span>
									</li>
								);
							})}
						</ul>
					))}
				</div>
			</div>
		</section>
	)
}

<style>
	.slc {
		/* ---- 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. */
		--slc-max-width: 80rem;
		--slc-padding-inline: 16px;
		--slc-margin-top: 48px;
		--slc-padding-bottom: 24px;
		--slc-label-gap: 24px;
		--slc-logo-height: 20px;
		--slc-cell-height: 40px;
		--slc-row-gap: 48px;
		--slc-column-gap: 16px;

		--slc-duration: 560ms;
		--slc-stagger: 45ms;
		--slc-ease: cubic-bezier(0.16, 1, 0.3, 1);
		--slc-blur: 6px;
		--slc-travel: 32px;
		--slc-exit-scale: 0.5;

		--slc-surface: transparent;
		--slc-label-color: #6b7280;
		--slc-logo-color: #52525b;
		--slc-ring: #6366f1;
		--slc-font: inherit;

		box-sizing: border-box;
		display: block;
		background: var(--slc-surface);
		font-family: var(--slc-font);
		/* A host page with `line-height: 1` would otherwise collapse the label. */
		line-height: 1.5;
	}

	/* `:where` keeps specificity at zero, so a destination project's own rules
	   still win where it means them to. */
	.slc :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.slc-inner {
		max-width: var(--slc-max-width);
		margin-inline: auto;
		padding-inline: var(--slc-padding-inline);
		padding-bottom: var(--slc-padding-bottom);
	}

	.slc-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	/* ---------- label and control ---------- */

	.slc-head {
		display: grid;
		/* Equal end columns keep the label centred on the section, not on the
		   space left over beside the button. */
		grid-template-columns: 44px 1fr 44px;
		align-items: center;
		gap: 8px;
		min-height: 44px;
	}

	.slc-label {
		margin: 0;
		text-align: center;
		color: var(--slc-label-color);
		font-size: 0.875rem;
	}

	.slc-controls {
		display: flex;
		justify-content: flex-end;
	}

	/*
	 * 44x44 minimum target, with the visible glyph smaller inside it. The
	 * reference has no control at all; see the notes in the README for why one
	 * is added here rather than leaving a timer the visitor cannot stop.
	 */
	.slc-toggle {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 44px;
		height: 44px;
		padding: 0;
		border: 0;
		border-radius: 999px;
		background: transparent;
		color: var(--slc-label-color);
		cursor: pointer;
	}

	.slc-toggle:hover {
		color: var(--slc-logo-color);
	}

	.slc-toggle:focus-visible {
		outline: 2px solid var(--slc-ring);
		outline-offset: 2px;
	}

	.slc-glyph {
		width: 10px;
		height: 12px;
		background: currentColor;
	}

	.slc-glyph-pause {
		/* Two bars, drawn with a clip rather than two elements. */
		clip-path: polygon(0 0, 35% 0, 35% 100%, 0 100%, 0 0, 65% 0, 100% 0, 100% 100%, 65% 100%, 65% 0);
	}

	.slc-glyph-play {
		clip-path: polygon(0 0, 100% 50%, 0 100%);
		display: none;
	}

	/* Pressed means "rotation is paused", so the control offers play. */
	.slc-toggle[aria-pressed="true"] .slc-glyph-pause {
		display: none;
	}

	.slc-toggle[aria-pressed="true"] .slc-glyph-play {
		display: block;
	}

	/* ---------- stage ---------- */

	.slc-stage {
		display: grid;
		margin-top: var(--slc-label-gap);
	}

	/*
	 * Every group occupies the same single grid cell. That is what makes the
	 * overlap during a transition free: there is no positioning to compute and
	 * no second layout to keep in step.
	 */
	.slc-grid {
		grid-area: 1 / 1;
		display: grid;
		grid-template-columns: repeat(3, minmax(0, 1fr));
		gap: var(--slc-row-gap) var(--slc-column-gap);
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.slc-cell {
		display: flex;
		align-items: center;
		justify-content: center;
		/* Fixed, so the stage height cannot depend on which logos are in it. */
		height: var(--slc-cell-height);
		min-width: 0;
	}

	.slc-logo {
		display: flex;
		align-items: center;
		justify-content: center;
		max-width: 100%;
		/* The animated element. Grayscale lives on the image inside it, so a
		   blur transition cannot drop it. */
		transition:
			opacity var(--slc-duration) var(--slc-ease),
			filter var(--slc-duration) var(--slc-ease),
			transform var(--slc-duration) var(--slc-ease);
		transition-delay: calc(var(--slc-i, 0) * var(--slc-stagger));
		will-change: opacity, transform, filter;
	}

	.slc-item {
		display: flex;
		align-items: center;
		justify-content: center;
		max-width: 100%;
		color: inherit;
		text-decoration: none;
		border-radius: 4px;
	}

	.slc-item:focus-visible {
		outline: 2px solid var(--slc-ring);
		outline-offset: 4px;
	}

	.slc-img {
		display: block;
		/* Height is the constant; width follows the intrinsic ratio. */
		height: var(--slc-logo-height);
		width: auto;
		max-width: 100%;
		object-fit: contain;
		filter: grayscale(1);
		opacity: 0.75;
	}

	/* ---------- text wordmarks ----------
	   What renders when an item has no `src`. Enough variation to judge spacing
	   and motion without shipping anybody's artwork. */

	.slc-mark {
		display: inline-flex;
		align-items: center;
		gap: 0.35em;
		max-width: 100%;
		color: var(--slc-logo-color);
		font-size: calc(var(--slc-logo-height) * 0.8);
		line-height: 1;
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
	}

	.slc-mark.is-plain {
		font-weight: 500;
	}

	.slc-mark.is-bold {
		font-weight: 800;
		letter-spacing: -0.02em;
	}

	.slc-mark.is-wide {
		font-weight: 500;
		letter-spacing: 0.22em;
		text-transform: uppercase;
		font-size: calc(var(--slc-logo-height) * 0.62);
	}

	.slc-mark.is-dot::before {
		content: "";
		flex: none;
		width: 0.5em;
		height: 0.5em;
		border-radius: 999px;
		background: currentColor;
	}

	.slc-mark.is-slash::before {
		content: "";
		flex: none;
		width: 0.16em;
		height: 1em;
		background: currentColor;
		transform: skewX(-18deg);
	}

	.slc-mark.is-box {
		font-weight: 700;
		text-transform: uppercase;
		font-size: calc(var(--slc-logo-height) * 0.6);
		letter-spacing: 0.06em;
	}

	.slc-mark.is-box::before {
		content: "";
		flex: none;
		width: 0.85em;
		height: 0.85em;
		border: 0.16em solid currentColor;
		border-radius: 0.2em;
	}

	/* ---------- group states ----------
	   Four states, and the whole animation is the transition between them. */

	/* Off stage. `visibility: hidden` also takes its links out of the tab
	   order, which is belt to the `tabindex="-1"` braces the controller sets. */
	.slc-grid[data-slc-state="idle"] {
		visibility: hidden;
	}

	.slc-grid[data-slc-state="idle"] .slc-logo {
		opacity: 0;
	}

	/* The starting pose of an arriving group: left of centre, blurred, clear. */
	.slc-grid[data-slc-state="enter"] .slc-logo {
		opacity: 0;
		filter: blur(var(--slc-blur));
		transform: translateX(calc(var(--slc-travel) * -1)) scale(1);
	}

	.slc-grid[data-slc-state="active"] .slc-logo {
		opacity: 1;
		filter: blur(0);
		transform: translateX(0) scale(1);
	}

	/* The finishing pose of a departing group: right of centre, blurred, small. */
	.slc-grid[data-slc-state="exit"] .slc-logo {
		opacity: 0;
		filter: blur(var(--slc-blur));
		transform: translateX(var(--slc-travel)) scale(var(--slc-exit-scale));
	}

	/* Used for one frame, to place a group in its starting pose without
	   animating it there. */
	.slc[data-slc-instant] .slc-logo {
		transition: none !important;
	}

	/* ---------- responsive ----------
	   Three columns, then four, then one row of however many cells the groups
	   normalised to — eight, for a set of eight. */

	@media (min-width: 640px) {
		.slc-grid {
			grid-template-columns: repeat(4, minmax(0, 1fr));
		}
	}

	@media (min-width: 1024px) {
		.slc-inner {
			margin-top: var(--slc-margin-top);
		}

		.slc-grid {
			grid-template-columns: repeat(var(--slc-cols, 8), minmax(0, 1fr));
			/* One row, so the row gap has nothing to act on. */
			gap: 0 var(--slc-column-gap);
		}
	}

	/* ---------- reduced motion ----------
	   The controller refuses to run a timer at all under this preference, so
	   there is nothing to animate; this makes any state change instant in case
	   one is triggered before the controller reads the preference. */
	@media (prefers-reduced-motion: reduce) {
		.slc-logo {
			transition-duration: 1ms;
			transition-delay: 0ms;
		}
	}
</style>

<script>
	/**
	 * StaggeredLogoCloud controller.
	 *
	 * One closure per instance. Every query is scoped to that instance's root,
	 * every listener carries an AbortController signal, and the whole thing is
	 * torn down before a client-side navigation swaps the document.
	 */
	interface Instance {
		destroy(): void;
	}

	function initStaggeredLogoCloud(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second set of listeners. */
		if (root.dataset.slcReady === "") return undefined;

		const stage = root.querySelector<HTMLElement>("[data-slc-stage]");
		const groups = [...root.querySelectorAll<HTMLElement>("[data-slc-group]")];
		if (!stage || groups.length === 0) return undefined;

		root.dataset.slcReady = "";

		const controller = new AbortController();
		const { signal } = controller;

		const cycles = root.dataset.slcCycles === "1" && groups.length > 1;
		const wantsAutoplay = root.dataset.slcAutoplay === "1" && cycles;
		const pauseOnHover = root.dataset.slcPauseHover === "1";
		const interval = Number(root.dataset.slcInterval) || 4000;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

		const toggle = root.querySelector<HTMLButtonElement>("[data-slc-toggle]");
		const toggleLabel = root.querySelector<HTMLElement>("[data-slc-toggle-label]");
		const pauseLabel = root.dataset.slcPauseLabel || "Pause logo rotation";
		const playLabel = root.dataset.slcPlayLabel || "Resume logo rotation";

		let index = 0;
		let transitioning = false;
		let timer: number | undefined;
		let settleTimer: number | undefined;

		let hovering = false;
		let focused = false;
		let manuallyPaused = false;

		/* How long the last logo is still moving: the shared duration plus the
		   delay the final cell waits before it starts. */
		function settleDuration(): number {
			const styles = getComputedStyle(root);
			const duration = Number.parseFloat(styles.getPropertyValue("--slc-duration")) || 560;
			const stagger = Number.parseFloat(styles.getPropertyValue("--slc-stagger")) || 45;
			const cells = groups[0]?.querySelectorAll("[data-slc-group] > *").length ?? 8;
			return duration + stagger * Math.max(cells - 1, 0) + 60;
		}

		function setInstant(on: boolean) {
			root.toggleAttribute("data-slc-instant", on);
			/* Flush, so the next attribute change animates from here. */
			if (on) void root.offsetWidth;
		}

		/**
		 * Expose exactly one group.
		 *
		 * `aria-hidden` and `tabindex` move together: a group that is not the
		 * active one must be neither announced nor tabbable, or a four-group
		 * cloud offers every link four times.
		 */
		function expose(group: HTMLElement, exposed: boolean) {
			if (exposed) group.removeAttribute("aria-hidden");
			else group.setAttribute("aria-hidden", "true");
			group.querySelectorAll<HTMLElement>("a[href]").forEach((link) => {
				if (exposed) link.removeAttribute("tabindex");
				else link.setAttribute("tabindex", "-1");
			});
		}

		function goTo(next: number) {
			/* A tick must never start a second transition over a running one. */
			if (transitioning || !cycles) return;
			const target = ((next % groups.length) + groups.length) % groups.length;
			if (target === index) return;

			const outgoing = groups[index];
			const incoming = groups[target];
			transitioning = true;

			/* Place the arriving group in its starting pose without animating
			   it there, then let it animate to rest on the next frame. */
			setInstant(true);
			incoming.dataset.slcState = "enter";
			expose(incoming, true);
			expose(outgoing, false);
			setInstant(false);

			requestAnimationFrame(() => {
				incoming.dataset.slcState = "active";
				outgoing.dataset.slcState = "exit";
			});

			index = target;

			window.clearTimeout(settleTimer);
			settleTimer = window.setTimeout(() => {
				/* The departed group goes off stage with no animation: it is
				   already invisible, so returning it to rest is free. */
				setInstant(true);
				outgoing.dataset.slcState = "idle";
				setInstant(false);
				transitioning = false;
			}, settleDuration());
		}

		/* ---- autoplay ---- */

		/*
		 * Reduced motion is decisive, not advisory: the timer never runs under
		 * it, and pressing play cannot start one. A preference the visitor set
		 * is not something a control on the page gets to override.
		 */
		const autoplayAllowed = () => wantsAutoplay && !reduced.matches;

		function canRun(): boolean {
			if (!autoplayAllowed()) return false;
			if (manuallyPaused) return false;
			if (document.hidden) return false;
			if (pauseOnHover && hovering) return false;
			if (focused) return false;
			return true;
		}

		function stopAuto() {
			if (timer !== undefined) window.clearInterval(timer);
			timer = undefined;
		}

		function startAuto() {
			stopAuto();
			if (!canRun()) return;
			timer = window.setInterval(() => goTo(index + 1), interval);
		}

		const refreshAuto = () => (canRun() ? startAuto() : stopAuto());

		function syncToggle() {
			if (!toggle) return;
			const paused = manuallyPaused;
			toggle.setAttribute("aria-pressed", paused ? "true" : "false");
			if (toggleLabel) toggleLabel.textContent = paused ? playLabel : pauseLabel;
		}

		if (autoplayAllowed() && toggle) {
			/* Only revealed once there is a controller behind it. A control that
			   cannot do anything is worse than no control. */
			toggle.hidden = false;
			syncToggle();
			toggle.addEventListener(
				"click",
				() => {
					manuallyPaused = !manuallyPaused;
					syncToggle();
					refreshAuto();
				},
				{ signal },
			);
		}

		if (autoplayAllowed()) {
			if (pauseOnHover) {
				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 });
			reduced.addEventListener("change", refreshAuto, { signal });
		}

		/* Initial exposure, in case the markup and the controller disagree. */
		groups.forEach((group, i) => expose(group, i === index));

		refreshAuto();

		function destroy() {
			stopAuto();
			window.clearTimeout(settleTimer);
			groups.forEach((group, i) => {
				group.dataset.slcState = i === 0 ? "active" : "idle";
				expose(group, i === 0);
			});
			root.removeAttribute("data-slc-instant");
			controller.abort();
			delete root.dataset.slcReady;
		}

		/*
		 * Astro replaces the document on a client-side navigation. Stop the
		 * timer, return every group to the state it was served in, detach every
		 * listener, and clear the ready flag so a re-used element can boot again.
		 */
		document.addEventListener("astro:before-swap", destroy, { once: true, signal });

		return { destroy };
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-slc-root]").forEach(initStaggeredLogoCloud);

	/* Astro's own <script> is a deferred module, so the DOM is already parsed
	   by the time this runs. The readyState check is for the case where this
	   file is copied into a project that inlines it some other way. */
	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	/* Fires on every view transition in a project using <ClientRouter />, and
	   never at all in one that does not. Registered once per module, so it
	   cannot accumulate. */
	document.addEventListener("astro:page-load", boot);
</script>

===== END src/components/StaggeredLogoCloud.astro =====

===== FILE (supporting): src/components/StaggeredLogoCloud.md =====
# Staggered logo cloud

A centred "trusted by" row whose entire set of logos is replaced on a timer.
Each logo blurs out to the right and shrinks away while its replacement arrives
from the left, one cell after another, left to right.

There are no arrows, no dots, no drag and no swipe. The only control is a
play/pause button, which the reference does not have and this component does —
see [Accessibility](#accessibility).

Catalog page: `/components/staggered-logo-cloud`

## Purpose

For the band of customer or partner logos that sits under a hero. Rotating the
set lets a page show twenty-four names in the space of eight without a marquee,
and without the horizontal scrolling that makes a marquee hard to read and
harder to stop.

> **Design credit.** The geometry and motion were modelled on the public
> [Tailark](https://pro.tailark.com/) "Trusted by people from" section. **No
> source code, markup, CSS, script, branding, logo or artwork from that site is
> used here**, and every demo wordmark in this catalog is invented for this
> component. The reference is a React/Next component; this is native Astro with
> no dependencies. Differences are listed under
> [Differences from the reference](#differences-from-the-reference).

## Files required

**`StaggeredLogoCloud.astro`** — one file, the whole component.

- No imports — not from this catalog, not from anywhere.
- No npm packages, no animation library, no React, no Tailwind, no CSS reset.
- No global stylesheet. Every colour, size, duration and easing is declared on
  its own root element as a `--slc-*` custom property, so it inherits nothing
  from the host page and leaks nothing back.
- No webfonts, no bundled images, **no network requests of any kind**. Images
  are whatever you pass in `src`; pass nothing and you get a text wordmark.
- The only assumption about the host is that Astro compiles `.astro` files.

`StaggeredLogoCloud.md` — this file. Documentation, not required to run.

## Basic usage

```astro
---
import StaggeredLogoCloud from "../components/library/StaggeredLogoCloud.astro";

const groups = [
  {
    id: "set-a",
    logos: [
      { id: "northwind", name: "Northwind", src: "/logos/northwind.svg", width: 120, height: 20 },
      { id: "kestrel", name: "Kestrel", src: "/logos/kestrel.svg", width: 96, height: 20 },
      // ...six more
    ],
  },
  { id: "set-b", logos: [/* eight more */] },
];
---

<StaggeredLogoCloud groups={groups} />
```

That is the whole integration. No client directive, no wrapper, no init call —
the component ships its own module script and boots itself.

## Props

`groups` is required; everything else has a default.

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `groups` | `LogoCloudGroup[]` | — | The sets to cycle through. Groups with no usable logos are dropped; if none survive, nothing renders at all. |
| `label` | `string` | `"Trusted by people from"` | The muted line above the logos, and the region's accessible name. |
| `autoplay` | `boolean` | `true` | Advance on a timer. |
| `interval` | `number` | `4000` | Milliseconds each set is held. Floored at `1500`. |
| `pauseOnHover` | `boolean` | `true` | Hold the timer while the pointer is over the component. |
| `showPauseControl` | `boolean` | `true` | Render the play/pause control. Only ever shown when autoplay can actually run. |
| `pauseLabel` | `string` | `"Pause logo rotation"` | Accessible name while the control would pause. |
| `playLabel` | `string` | `"Resume logo rotation"` | Accessible name while the control would resume. |
| `class` | `string?` | — | Extra class on the `<section>`. |
| `id` | `string?` | — | Section id. Also seeds the internal ids instead of a random value. |

### `LogoCloudItem`

| Field | Type | What it does |
| :--- | :--- | :--- |
| `id` | `string` | Stable identity. Items without one are dropped. |
| `name` | `string` | The name. Also the accessible name and the text wordmark. Items without one are dropped. |
| `src` | `string?` | Image URL. Omit for a text wordmark. |
| `alt` | `string?` | Defaults to `name`. An empty string marks the image decorative. |
| `href` | `string?` | Destination. An unsafe value renders as inert text, never a link. |
| `external` | `boolean?` | Opens in a new tab with `rel="noopener noreferrer"`. |
| `width` / `height` | `number?` | Intrinsic size, so the browser reserves the right box before the image loads. |
| `darkMode` | `"none" \| "invert"` | `invert` flips a dark-on-light image under a dark colour scheme. |
| `wordmark` | `"plain" \| "bold" \| "wide" \| "dot" \| "slash" \| "box"` | Treatment for the text wordmark. See below. |

### `LogoCloudGroup`

| Field | Type | What it does |
| :--- | :--- | :--- |
| `id` | `string` | Stable identity. Duplicate ids are dropped. |
| `logos` | `LogoCloudItem[]` | The set. An empty set drops the group. |

### Two API notes

**`wordmark` is an addition.** The reference ships real logo files. A component
library cannot, so a `src`-less item renders a text wordmark, and `wordmark`
picks its treatment. It is what lets the preview show eight visually distinct
marks with no external assets, and it gives a destination project something
reasonable to render before its own files arrive.

**There is no `previousLabel`.** There is no previous control to label — this
component has no manual navigation by design, matching the reference. A prop
that named a control which does not exist would be a prop that lies.

## Data is validated

Item data usually comes from a CMS, and an `href` becomes an attribute on every
render:

- **Allowed:** root-relative (`/about/`), bare relative (`about/`), fragments
  (`#partners`), `./` and `../`, and `http:`, `https:`, `mailto:`, `tel:`.
- **Refused:** everything else, including `javascript:`, `data:` and
  `vbscript:` in any casing, with leading whitespace, or with control
  characters inserted mid-scheme. Control characters are stripped before the
  scheme test, so that trick does not get past it.
- A refused `href` renders the logo as **inert text** — visible, not a link.
- `src` goes through a narrower check that refuses `javascript:`/`vbscript:`
  but allows `data:`, because an inline SVG is a legitimate image.
- Text is rendered with ordinary interpolation. **There is no `set:html`
  anywhere in the component**, and no prop accepts markup.

Verified in the built HTML: `javascript:`, `JaVaScRiPt:` with leading spaces,
`data:text/html` and `vbscript:` all rendered as `<span class="slc-item">`, and
none of those strings reached the document.

## CSS custom properties

| Property | Default | What it controls |
| :--- | :--- | :--- |
| `--slc-max-width` | `80rem` | Maximum content width |
| `--slc-padding-inline` | `16px` | Horizontal container padding |
| `--slc-margin-top` | `48px` | Top margin, from 1024px up |
| `--slc-padding-bottom` | `24px` | Bottom padding |
| `--slc-label-gap` | `24px` | Label-to-logo gap |
| `--slc-logo-height` | `20px` | Displayed logo height |
| `--slc-cell-height` | `40px` | Logo cell height |
| `--slc-row-gap` | `48px` | Row gap while the grid wraps |
| `--slc-column-gap` | `16px` | Column gap |
| `--slc-duration` | `560ms` | Transition duration |
| `--slc-stagger` | `45ms` | Delay between consecutive logos |
| `--slc-ease` | `cubic-bezier(0.16, 1, 0.3, 1)` | Easing |
| `--slc-blur` | `6px` | Blur at both ends of the transition |
| `--slc-travel` | `32px` | Horizontal travel in and out |
| `--slc-exit-scale` | `0.5` | Scale an outgoing logo shrinks to |
| `--slc-surface` | `transparent` | Background behind the section |
| `--slc-label-color` | `#6b7280` | Label and control colour |
| `--slc-logo-color` | `#52525b` | Text wordmark colour |
| `--slc-ring` | `#6366f1` | Focus ring colour |
| `--slc-font` | `inherit` | Font stack |

`--slc-cols` is written by the component from the normalised cell count and
should not be set by hand.

## Geometry

Reproduced from the reference measurements:

| | Value |
| :--- | :--- |
| Max content width | `80rem` (1280px) |
| Horizontal padding | 16px |
| Desktop top margin | 48px |
| Bottom padding | 24px |
| Label-to-logo gap | 24px |
| Cell height | 40px |
| Logo height | 20px, width automatic |
| Logo treatment | greyscale, 75% opacity |

## Responsive behaviour

| Width | Columns |
| :--- | :--- |
| Below 640px | 3 |
| 640px and up | 4 |
| 1024px and up | One row of `--slc-cols` — eight, for sets of eight |

Row gap is 48px while the grid wraps, and collapses to nothing in the
single-row layout where it has nothing to act on.

## How the animation works

Four states on each group, and the whole animation is the transition between
them:

| State | Pose |
| :--- | :--- |
| `idle` | Off stage, `visibility: hidden` |
| `enter` | `opacity: 0`, `blur(6px)`, `translateX(-32px) scale(1)` |
| `active` | `opacity: 1`, `blur(0)`, `translateX(0) scale(1)` |
| `exit` | `opacity: 0`, `blur(6px)`, `translateX(32px) scale(0.5)` |

Stagger is one custom property per cell — `--slc-i` — feeding
`transition-delay: calc(var(--slc-i) * var(--slc-stagger))`. There is no
per-frame scripting and no animation library.

**All groups occupy the same grid area.** Each is a layer at `grid-area: 1 / 1`
inside one stage, so the cell an outgoing logo leaves is the exact cell its
replacement arrives in, and the stage is always exactly one group tall. Nothing
is measured at runtime and nothing reflows mid-transition.

**Groups are normalised to a common cell count.** A group of six beside a group
of eight is padded with empty cells rather than laid out on its own terms, so a
short set cannot make the page jump when its turn comes. Verified: a two-logo
group beside an eight-logo group renders eight cells, six of them empty.

**The loop boundary is not a special case.** The next group is `(i + 1) % count`
and nothing else — no branch, no reset, no snap. The last-to-first transition
runs the same code path, duration, easing and stagger as every other one.

**A tick cannot interrupt a running transition.** The controller returns early
while one is in flight, so a short interval degrades into "waits its turn"
rather than into a corrupted state.

## Accessibility

- The root is a `<section>` whose accessible name is the `label`.
- **Only the active group is exposed.** Inactive groups carry
  `aria-hidden="true"`, and every link inside them carries `tabindex="-1"`, so a
  three-group cloud offers each link exactly once. Verified in the built HTML.
- **No live region.** A logo set changing on a timer is decorative; announcing
  every rotation would interrupt a screen-reader user several times a minute
  for nothing.
- Links are real anchors. The control is a real `<button>`.
- The control is **44×44px**, with a smaller glyph inside it.
- Visible focus indicators on the control and on every link.

### The pause control is a deliberate addition

The reference rotates on a timer with no way to stop it. That is a
[WCAG 2.2.2 Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html)
problem: content that moves automatically for more than five seconds needs a
mechanism to pause it. This component adds one rather than reproducing the
omission.

It is only rendered when it would do something — with one group, with
`autoplay={false}`, or under reduced motion, there is no timer to pause and no
control appears. It also ships with the `hidden` attribute and is revealed by
the controller, so a scriptless page never shows a button that cannot work.

## Reduced motion

Under `prefers-reduced-motion: reduce`:

- **Automatic rotation never starts.** The timer is not created at all.
- The control is not rendered, because there is nothing for it to pause.
- **Pressing play cannot override the preference.** A preference the visitor set
  is not something a button on the page gets to overrule.
- Transition durations collapse to 1ms, so any state change that does occur
  arrives instead of travelling.

## Without JavaScript

The first group renders active and visible; the others are `visibility: hidden`.
The control ships with the `hidden` attribute and is only revealed by the
controller, so nothing non-functional is offered. Links in the first group are
ordinary, tabbable anchors.

## Multiple instances

Any number can share a page:

- Every query is scoped to that instance's root.
- All state lives in the controller's closure.
- Ids are minted per instance from a random suffix, or seeded from `id`.
- A `data-slc-ready` flag makes initialisation idempotent.

## Astro navigation

- Boots on `DOMContentLoaded` and on `astro:page-load`.
- Every listener is registered with an `AbortController` signal.
- `astro:before-swap` clears the interval and the settle timer, returns every
  group to the state it was served in, and clears the ready flag.
- **No global body classes, no scroll locks, no permanent inline styles.**

## Copying it into another Astro project

1. Copy `StaggeredLogoCloud.astro` anywhere under `src/`.
2. Import it and pass `groups`. There is nothing to install and nothing to
   configure.
3. Optionally set `--slc-*` properties to match your palette.

## Differences from the reference

| | Reference | Here | Why |
| :--- | :--- | :--- | :--- |
| Implementation | React / Next component | Native Astro, no dependencies | Portability |
| Styling | Tailwind utilities | Component-scoped CSS custom properties | No framework dependency in a destination project |
| Pause control | None | 44×44 play/pause, shown only when useful | WCAG 2.2.2 |
| Reduced motion | Not honoured | Rotation disabled entirely | Accessibility |
| Inactive sets | — | `aria-hidden` + untabbable links | No duplicate tab stops |
| Unequal sets | — | Normalised to a common cell count | A short set cannot shift the page |
| Logos | Real brand assets | Your `src`, or a text wordmark | A library cannot ship other people's artwork |

## Known limitations

- **The motion has not been measured in a browser.** The durations, stagger,
  blur, travel and easing are the reference measurements supplied with the
  brief, implemented faithfully but **not yet verified against a rendered
  page**. Do not describe the animation as matching the reference until it has
  been. The component is enrolled in the readiness system and currently reads
  **Not yet verified** for exactly this reason.
- **Focus inside an outgoing group.** Autoplay pauses whenever focus is inside
  the component, so a transition should not begin while a link is focused. If
  one is forced (by calling the control's handler directly, say), the outgoing
  group would briefly be `aria-hidden` while holding focus.
- **One rotation speed for all groups.** `interval` is global; a per-group dwell
  time would need a different prop shape.
- **No manual navigation.** By design, matching the reference. If a visitor
  wants to see a set again they must wait for it to come round.

===== END src/components/StaggeredLogoCloud.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to 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.

--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.

--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Staggered logo cloud” from my Astro Component Library:
https://astro.baysixmedia.com/components/staggered-logo-cloud/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Ships script
Profile
core only
Installation files
1 required + 1 supporting
Verified
Never
Client navigation
Event-tested only — not certified against a real router
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/StaggeredLogoCloud.astro

one fileNo imports, no companion files, no packages. Copy StaggeredLogoCloud.astro into any Astro project's components folder and render it.

---
/**
 * StaggeredLogoCloud — a "trusted by" logo row whose whole set is replaced on a
 * timer, each logo leaving to the right and arriving from the left a beat after
 * the one before it.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * 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 Tailark "Trusted by people from"
 * section (https://pro.tailark.com/), from the measurements recorded in
 * StaggeredLogoCloud.md. No source, markup, CSS, script, branding, logo or
 * artwork from that site is used here, and the demo wordmarks are original.
 *
 * The moving parts, and why they are shaped this way:
 *
 *   - Every group is a grid layer stacked in the same `grid-area`, so the eight
 *     cells an outgoing logo leaves are the exact cells its replacement arrives
 *     in. Nothing is measured at runtime and nothing reflows mid-transition.
 *   - Stagger is one custom property per cell (`--slc-i`) feeding a
 *     `transition-delay`. No per-frame scripting, no animation library.
 *   - Groups are normalised to a common cell count, so a group of six and a
 *     group of eight occupy identical geometry and a short group cannot make
 *     the page jump when it takes its turn.
 *   - The wrap from the last group to the first is `(i + 1) % count` and
 *     nothing else. There is no branch at the boundary, so there is nothing
 *     that could look different there.
 *
 * 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 LogoCloudItem {
	/** Stable identity. Used for the cell's key and for the rendered id. */
	id: string;
	/** The company or product name. Also the accessible name, and the wordmark. */
	name: string;
	/** Image URL. Omit to render a text wordmark instead. */
	src?: string;
	/** Alt text. Defaults to the name; an empty string marks it decorative. */
	alt?: string;
	/** Optional destination. An unsafe value renders as inert, non-linked text. */
	href?: string;
	/** Opens in a new tab with the matching rel. */
	external?: boolean;
	/** Intrinsic width, so the browser can reserve the right box. */
	width?: number;
	/** Intrinsic height, so the browser can reserve the right box. */
	height?: number;
	/** `invert` flips a dark-on-light image under a dark colour scheme. */
	darkMode?: "none" | "invert";
	/**
	 * Treatment for the text wordmark drawn when there is no `src`.
	 *
	 * Not in the reference, which ships real logos. It exists so a library
	 * preview can show eight visually distinct marks without copying anybody's
	 * artwork, and so a destination project has something reasonable to render
	 * before its own assets arrive.
	 */
	wordmark?: "plain" | "bold" | "wide" | "dot" | "slash" | "box";
}

export interface LogoCloudGroup {
	/** Stable identity. Used for the group's rendered id. */
	id: string;
	logos: LogoCloudItem[];
}

export interface StaggeredLogoCloudProps {
	/** The sets of logos to cycle through. */
	groups: LogoCloudGroup[];
	/** The muted line above the logos, and the region's accessible name. */
	label?: string;
	/** Advance through the groups on a timer. */
	autoplay?: boolean;
	/** Milliseconds each group is held before the next one arrives. */
	interval?: number;
	/** Hold the timer while the pointer is over the component. */
	pauseOnHover?: boolean;
	/** Render the play/pause control. Only ever shown when autoplay can run. */
	showPauseControl?: boolean;
	/** Accessible name for the control while it would pause. */
	pauseLabel?: string;
	/** Accessible name for the control while it would resume. */
	playLabel?: string;
	class?: string;
	id?: string;
}

interface Props extends StaggeredLogoCloudProps {}

const {
	groups,
	label = "Trusted by people from",
	autoplay = true,
	interval = 4000,
	pauseOnHover = true,
	showPauseControl = true,
	pauseLabel = "Pause logo rotation",
	playLabel = "Resume logo rotation",
	class: className,
	id,
}: Props = Astro.props;

/*
 * Item data is treated as untrusted: it typically comes from a CMS or a
 * content collection, and an 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;

/** Control characters are stripped first: "java\tscript:" is a scheme too. */
function stripControls(value: string): string {
	return [...value]
		.filter((ch) => {
			const code = ch.codePointAt(0) ?? 0;
			return code > 31 && code !== 127;
		})
		.join("");
}

function safeHref(value: unknown): string | undefined {
	if (typeof value !== "string") return undefined;
	const raw = value.trim();
	if (raw === "") return undefined;
	const bare = stripControls(raw).trim();
	if (bare === "") return undefined;
	if (
		bare.startsWith("/") ||
		bare.startsWith("#") ||
		bare.startsWith("./") ||
		bare.startsWith("../")
	) {
		return bare;
	}
	if (SAFE_SCHEME.test(bare)) return bare;
	/* A bare relative path has no scheme before its first slash. */
	const colon = bare.indexOf(":");
	const slash = bare.indexOf("/");
	if (colon === -1 || (slash !== -1 && slash < colon)) return bare;
	return undefined;
}

/**
 * Image sources are checked more narrowly than links.
 *
 * `data:` is allowed here and refused for hrefs, because an inline SVG is a
 * legitimate image and a legitimate navigation target is never a data URL.
 */
function safeSrc(value: unknown): string | undefined {
	if (typeof value !== "string") return undefined;
	const raw = value.trim();
	if (raw === "") return undefined;
	const bare = stripControls(raw);
	if (/^\s*(javascript|vbscript):/i.test(bare)) return undefined;
	return raw;
}

const WORDMARKS = new Set(["plain", "bold", "wide", "dot", "slash", "box"]);

interface ResolvedLogo {
	key: string;
	name: string;
	src?: string;
	alt: string;
	href?: string;
	external: boolean;
	width?: number;
	height?: number;
	invert: boolean;
	wordmark: string;
}

const seenGroupIds = new Set<string>();

/*
 * Groups with nothing usable in them are dropped rather than rendered as empty
 * rows. A set of no logos is not an empty set of logos — it is a data mistake,
 * and showing a blank beat in the rotation would make it look deliberate.
 */
const resolvedGroups = (Array.isArray(groups) ? groups : [])
	.map((group) => {
		if (!group || typeof group.id !== "string" || group.id.trim() === "") return null;
		const logos: ResolvedLogo[] = (Array.isArray(group.logos) ? group.logos : [])
			.filter(
				(logo): logo is LogoCloudItem =>
					Boolean(logo) && typeof logo.id === "string" && typeof logo.name === "string",
			)
			.filter((logo) => logo.id.trim() !== "" && logo.name.trim() !== "")
			.map((logo) => {
				const href = safeHref(logo.href);
				const wordmark =
					typeof logo.wordmark === "string" && WORDMARKS.has(logo.wordmark)
						? logo.wordmark
						: "plain";
				return {
					key: logo.id.trim(),
					name: logo.name.trim(),
					src: safeSrc(logo.src),
					alt: typeof logo.alt === "string" ? logo.alt : logo.name.trim(),
					href,
					external: href !== undefined && logo.external === true,
					width: Number.isFinite(logo.width) ? Number(logo.width) : undefined,
					height: Number.isFinite(logo.height) ? Number(logo.height) : undefined,
					invert: logo.darkMode === "invert",
					wordmark,
				};
			});
		if (logos.length === 0) return null;
		const groupId = group.id.trim();
		if (seenGroupIds.has(groupId)) return null;
		seenGroupIds.add(groupId);
		return { key: groupId, logos };
	})
	.filter((group): group is { key: string; logos: ResolvedLogo[] } => group !== null);

/*
 * One cell count for every group, taken from the largest.
 *
 * A group with fewer logos is padded with empty cells rather than laid out on
 * its own terms. Identical geometry is what lets the stage keep its height and
 * lets an outgoing logo hand its exact cell to the one replacing it.
 */
const cellCount = resolvedGroups.reduce((max, group) => Math.max(max, group.logos.length), 0);

const groupCount = resolvedGroups.length;
/* One group is a logo row, not a rotation: no timer, no control, no layers. */
const cycles = groupCount > 1;

/* Floored so a mistyped interval cannot produce a strobe. */
const safeInterval = Math.max(1500, Number(interval) || 4000);

/*
 * 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 ?? `slc-${Math.random().toString(36).slice(2, 9)}`;
const stageId = `${uid}-stage`;
---

{
	groupCount > 0 && (
		<section
			class:list={["slc", className]}
			id={id}
			style={`--slc-cols:${cellCount}`}
			data-slc-root
			data-slc-cycles={cycles ? "1" : "0"}
			data-slc-autoplay={autoplay ? "1" : "0"}
			data-slc-interval={String(safeInterval)}
			data-slc-pause-hover={pauseOnHover ? "1" : "0"}
			data-slc-control={showPauseControl ? "1" : "0"}
			data-slc-pause-label={pauseLabel}
			data-slc-play-label={playLabel}
			aria-label={label}
		>
			<div class="slc-inner">
				{/*
				    Three columns rather than a centred flex row: the label stays
				    optically centred in the section whether or not the control is
				    rendered, and the control can never overlap it at any width.
				*/}
				<div class="slc-head">
					<span class="slc-spacer" aria-hidden="true" />
					<p class="slc-label">{label}</p>
					<div class="slc-controls">
						{cycles && autoplay && showPauseControl && (
							<button
								class="slc-toggle"
								type="button"
								data-slc-toggle
								aria-controls={stageId}
								aria-pressed="false"
								hidden
							>
								<span class="slc-sr" data-slc-toggle-label>
									{pauseLabel}
								</span>
								<span class="slc-glyph slc-glyph-pause" aria-hidden="true" />
								<span class="slc-glyph slc-glyph-play" aria-hidden="true" />
							</button>
						)}
					</div>
				</div>

				{/*
				    Every group is a layer in one grid cell of this stage, so the
				    stage is as tall as a group and stays that height while two of
				    them overlap.
				*/}
				<div class="slc-stage" id={stageId} data-slc-stage>
					{resolvedGroups.map((group, groupIndex) => (
						<ul
							class="slc-grid"
							data-slc-group
							data-slc-group-index={String(groupIndex)}
							data-slc-state={groupIndex === 0 ? "active" : "idle"}
							aria-hidden={groupIndex === 0 ? undefined : "true"}
						>
							{Array.from({ length: cellCount }).map((_, cellIndex) => {
								const logo = group.logos[cellIndex];
								if (!logo) {
									/* A padding cell. It holds the column open so a short
									   group cannot change the geometry when its turn comes. */
									return <li class="slc-cell" style={`--slc-i:${cellIndex}`} aria-hidden="true" />;
								}
								/* One wrapper, whose tag depends on whether the item ended up
								   with a usable href. Rendering the media twice — once inside
								   a link and once outside it — is how the two copies drift. */
								const Item = logo.href ? "a" : "span";
								return (
									<li class="slc-cell" style={`--slc-i:${cellIndex}`}>
										<span class="slc-logo">
											<Item
												class="slc-item"
												href={logo.href}
												target={logo.external ? "_blank" : undefined}
												rel={logo.external ? "noopener noreferrer" : undefined}
												tabindex={logo.href && groupIndex !== 0 ? "-1" : undefined}
											>
												{logo.src ? (
													<img
														class:list={["slc-img", logo.invert && "is-invertible"]}
														src={logo.src}
														alt={logo.alt}
														width={logo.width}
														height={logo.height}
														loading="lazy"
														decoding="async"
														draggable="false"
													/>
												) : (
													<span class:list={["slc-mark", `is-${logo.wordmark}`]}>{logo.name}</span>
												)}
											</Item>
										</span>
									</li>
								);
							})}
						</ul>
					))}
				</div>
			</div>
		</section>
	)
}

<style>
	.slc {
		/* ---- 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. */
		--slc-max-width: 80rem;
		--slc-padding-inline: 16px;
		--slc-margin-top: 48px;
		--slc-padding-bottom: 24px;
		--slc-label-gap: 24px;
		--slc-logo-height: 20px;
		--slc-cell-height: 40px;
		--slc-row-gap: 48px;
		--slc-column-gap: 16px;

		--slc-duration: 560ms;
		--slc-stagger: 45ms;
		--slc-ease: cubic-bezier(0.16, 1, 0.3, 1);
		--slc-blur: 6px;
		--slc-travel: 32px;
		--slc-exit-scale: 0.5;

		--slc-surface: transparent;
		--slc-label-color: #6b7280;
		--slc-logo-color: #52525b;
		--slc-ring: #6366f1;
		--slc-font: inherit;

		box-sizing: border-box;
		display: block;
		background: var(--slc-surface);
		font-family: var(--slc-font);
		/* A host page with `line-height: 1` would otherwise collapse the label. */
		line-height: 1.5;
	}

	/* `:where` keeps specificity at zero, so a destination project's own rules
	   still win where it means them to. */
	.slc :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.slc-inner {
		max-width: var(--slc-max-width);
		margin-inline: auto;
		padding-inline: var(--slc-padding-inline);
		padding-bottom: var(--slc-padding-bottom);
	}

	.slc-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	/* ---------- label and control ---------- */

	.slc-head {
		display: grid;
		/* Equal end columns keep the label centred on the section, not on the
		   space left over beside the button. */
		grid-template-columns: 44px 1fr 44px;
		align-items: center;
		gap: 8px;
		min-height: 44px;
	}

	.slc-label {
		margin: 0;
		text-align: center;
		color: var(--slc-label-color);
		font-size: 0.875rem;
	}

	.slc-controls {
		display: flex;
		justify-content: flex-end;
	}

	/*
	 * 44x44 minimum target, with the visible glyph smaller inside it. The
	 * reference has no control at all; see the notes in the README for why one
	 * is added here rather than leaving a timer the visitor cannot stop.
	 */
	.slc-toggle {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 44px;
		height: 44px;
		padding: 0;
		border: 0;
		border-radius: 999px;
		background: transparent;
		color: var(--slc-label-color);
		cursor: pointer;
	}

	.slc-toggle:hover {
		color: var(--slc-logo-color);
	}

	.slc-toggle:focus-visible {
		outline: 2px solid var(--slc-ring);
		outline-offset: 2px;
	}

	.slc-glyph {
		width: 10px;
		height: 12px;
		background: currentColor;
	}

	.slc-glyph-pause {
		/* Two bars, drawn with a clip rather than two elements. */
		clip-path: polygon(0 0, 35% 0, 35% 100%, 0 100%, 0 0, 65% 0, 100% 0, 100% 100%, 65% 100%, 65% 0);
	}

	.slc-glyph-play {
		clip-path: polygon(0 0, 100% 50%, 0 100%);
		display: none;
	}

	/* Pressed means "rotation is paused", so the control offers play. */
	.slc-toggle[aria-pressed="true"] .slc-glyph-pause {
		display: none;
	}

	.slc-toggle[aria-pressed="true"] .slc-glyph-play {
		display: block;
	}

	/* ---------- stage ---------- */

	.slc-stage {
		display: grid;
		margin-top: var(--slc-label-gap);
	}

	/*
	 * Every group occupies the same single grid cell. That is what makes the
	 * overlap during a transition free: there is no positioning to compute and
	 * no second layout to keep in step.
	 */
	.slc-grid {
		grid-area: 1 / 1;
		display: grid;
		grid-template-columns: repeat(3, minmax(0, 1fr));
		gap: var(--slc-row-gap) var(--slc-column-gap);
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.slc-cell {
		display: flex;
		align-items: center;
		justify-content: center;
		/* Fixed, so the stage height cannot depend on which logos are in it. */
		height: var(--slc-cell-height);
		min-width: 0;
	}

	.slc-logo {
		display: flex;
		align-items: center;
		justify-content: center;
		max-width: 100%;
		/* The animated element. Grayscale lives on the image inside it, so a
		   blur transition cannot drop it. */
		transition:
			opacity var(--slc-duration) var(--slc-ease),
			filter var(--slc-duration) var(--slc-ease),
			transform var(--slc-duration) var(--slc-ease);
		transition-delay: calc(var(--slc-i, 0) * var(--slc-stagger));
		will-change: opacity, transform, filter;
	}

	.slc-item {
		display: flex;
		align-items: center;
		justify-content: center;
		max-width: 100%;
		color: inherit;
		text-decoration: none;
		border-radius: 4px;
	}

	.slc-item:focus-visible {
		outline: 2px solid var(--slc-ring);
		outline-offset: 4px;
	}

	.slc-img {
		display: block;
		/* Height is the constant; width follows the intrinsic ratio. */
		height: var(--slc-logo-height);
		width: auto;
		max-width: 100%;
		object-fit: contain;
		filter: grayscale(1);
		opacity: 0.75;
	}

	/* ---------- text wordmarks ----------
	   What renders when an item has no `src`. Enough variation to judge spacing
	   and motion without shipping anybody's artwork. */

	.slc-mark {
		display: inline-flex;
		align-items: center;
		gap: 0.35em;
		max-width: 100%;
		color: var(--slc-logo-color);
		font-size: calc(var(--slc-logo-height) * 0.8);
		line-height: 1;
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
	}

	.slc-mark.is-plain {
		font-weight: 500;
	}

	.slc-mark.is-bold {
		font-weight: 800;
		letter-spacing: -0.02em;
	}

	.slc-mark.is-wide {
		font-weight: 500;
		letter-spacing: 0.22em;
		text-transform: uppercase;
		font-size: calc(var(--slc-logo-height) * 0.62);
	}

	.slc-mark.is-dot::before {
		content: "";
		flex: none;
		width: 0.5em;
		height: 0.5em;
		border-radius: 999px;
		background: currentColor;
	}

	.slc-mark.is-slash::before {
		content: "";
		flex: none;
		width: 0.16em;
		height: 1em;
		background: currentColor;
		transform: skewX(-18deg);
	}

	.slc-mark.is-box {
		font-weight: 700;
		text-transform: uppercase;
		font-size: calc(var(--slc-logo-height) * 0.6);
		letter-spacing: 0.06em;
	}

	.slc-mark.is-box::before {
		content: "";
		flex: none;
		width: 0.85em;
		height: 0.85em;
		border: 0.16em solid currentColor;
		border-radius: 0.2em;
	}

	/* ---------- group states ----------
	   Four states, and the whole animation is the transition between them. */

	/* Off stage. `visibility: hidden` also takes its links out of the tab
	   order, which is belt to the `tabindex="-1"` braces the controller sets. */
	.slc-grid[data-slc-state="idle"] {
		visibility: hidden;
	}

	.slc-grid[data-slc-state="idle"] .slc-logo {
		opacity: 0;
	}

	/* The starting pose of an arriving group: left of centre, blurred, clear. */
	.slc-grid[data-slc-state="enter"] .slc-logo {
		opacity: 0;
		filter: blur(var(--slc-blur));
		transform: translateX(calc(var(--slc-travel) * -1)) scale(1);
	}

	.slc-grid[data-slc-state="active"] .slc-logo {
		opacity: 1;
		filter: blur(0);
		transform: translateX(0) scale(1);
	}

	/* The finishing pose of a departing group: right of centre, blurred, small. */
	.slc-grid[data-slc-state="exit"] .slc-logo {
		opacity: 0;
		filter: blur(var(--slc-blur));
		transform: translateX(var(--slc-travel)) scale(var(--slc-exit-scale));
	}

	/* Used for one frame, to place a group in its starting pose without
	   animating it there. */
	.slc[data-slc-instant] .slc-logo {
		transition: none !important;
	}

	/* ---------- responsive ----------
	   Three columns, then four, then one row of however many cells the groups
	   normalised to — eight, for a set of eight. */

	@media (min-width: 640px) {
		.slc-grid {
			grid-template-columns: repeat(4, minmax(0, 1fr));
		}
	}

	@media (min-width: 1024px) {
		.slc-inner {
			margin-top: var(--slc-margin-top);
		}

		.slc-grid {
			grid-template-columns: repeat(var(--slc-cols, 8), minmax(0, 1fr));
			/* One row, so the row gap has nothing to act on. */
			gap: 0 var(--slc-column-gap);
		}
	}

	/* ---------- reduced motion ----------
	   The controller refuses to run a timer at all under this preference, so
	   there is nothing to animate; this makes any state change instant in case
	   one is triggered before the controller reads the preference. */
	@media (prefers-reduced-motion: reduce) {
		.slc-logo {
			transition-duration: 1ms;
			transition-delay: 0ms;
		}
	}
</style>

<script>
	/**
	 * StaggeredLogoCloud controller.
	 *
	 * One closure per instance. Every query is scoped to that instance's root,
	 * every listener carries an AbortController signal, and the whole thing is
	 * torn down before a client-side navigation swaps the document.
	 */
	interface Instance {
		destroy(): void;
	}

	function initStaggeredLogoCloud(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second set of listeners. */
		if (root.dataset.slcReady === "") return undefined;

		const stage = root.querySelector<HTMLElement>("[data-slc-stage]");
		const groups = [...root.querySelectorAll<HTMLElement>("[data-slc-group]")];
		if (!stage || groups.length === 0) return undefined;

		root.dataset.slcReady = "";

		const controller = new AbortController();
		const { signal } = controller;

		const cycles = root.dataset.slcCycles === "1" && groups.length > 1;
		const wantsAutoplay = root.dataset.slcAutoplay === "1" && cycles;
		const pauseOnHover = root.dataset.slcPauseHover === "1";
		const interval = Number(root.dataset.slcInterval) || 4000;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

		const toggle = root.querySelector<HTMLButtonElement>("[data-slc-toggle]");
		const toggleLabel = root.querySelector<HTMLElement>("[data-slc-toggle-label]");
		const pauseLabel = root.dataset.slcPauseLabel || "Pause logo rotation";
		const playLabel = root.dataset.slcPlayLabel || "Resume logo rotation";

		let index = 0;
		let transitioning = false;
		let timer: number | undefined;
		let settleTimer: number | undefined;

		let hovering = false;
		let focused = false;
		let manuallyPaused = false;

		/* How long the last logo is still moving: the shared duration plus the
		   delay the final cell waits before it starts. */
		function settleDuration(): number {
			const styles = getComputedStyle(root);
			const duration = Number.parseFloat(styles.getPropertyValue("--slc-duration")) || 560;
			const stagger = Number.parseFloat(styles.getPropertyValue("--slc-stagger")) || 45;
			const cells = groups[0]?.querySelectorAll("[data-slc-group] > *").length ?? 8;
			return duration + stagger * Math.max(cells - 1, 0) + 60;
		}

		function setInstant(on: boolean) {
			root.toggleAttribute("data-slc-instant", on);
			/* Flush, so the next attribute change animates from here. */
			if (on) void root.offsetWidth;
		}

		/**
		 * Expose exactly one group.
		 *
		 * `aria-hidden` and `tabindex` move together: a group that is not the
		 * active one must be neither announced nor tabbable, or a four-group
		 * cloud offers every link four times.
		 */
		function expose(group: HTMLElement, exposed: boolean) {
			if (exposed) group.removeAttribute("aria-hidden");
			else group.setAttribute("aria-hidden", "true");
			group.querySelectorAll<HTMLElement>("a[href]").forEach((link) => {
				if (exposed) link.removeAttribute("tabindex");
				else link.setAttribute("tabindex", "-1");
			});
		}

		function goTo(next: number) {
			/* A tick must never start a second transition over a running one. */
			if (transitioning || !cycles) return;
			const target = ((next % groups.length) + groups.length) % groups.length;
			if (target === index) return;

			const outgoing = groups[index];
			const incoming = groups[target];
			transitioning = true;

			/* Place the arriving group in its starting pose without animating
			   it there, then let it animate to rest on the next frame. */
			setInstant(true);
			incoming.dataset.slcState = "enter";
			expose(incoming, true);
			expose(outgoing, false);
			setInstant(false);

			requestAnimationFrame(() => {
				incoming.dataset.slcState = "active";
				outgoing.dataset.slcState = "exit";
			});

			index = target;

			window.clearTimeout(settleTimer);
			settleTimer = window.setTimeout(() => {
				/* The departed group goes off stage with no animation: it is
				   already invisible, so returning it to rest is free. */
				setInstant(true);
				outgoing.dataset.slcState = "idle";
				setInstant(false);
				transitioning = false;
			}, settleDuration());
		}

		/* ---- autoplay ---- */

		/*
		 * Reduced motion is decisive, not advisory: the timer never runs under
		 * it, and pressing play cannot start one. A preference the visitor set
		 * is not something a control on the page gets to override.
		 */
		const autoplayAllowed = () => wantsAutoplay && !reduced.matches;

		function canRun(): boolean {
			if (!autoplayAllowed()) return false;
			if (manuallyPaused) return false;
			if (document.hidden) return false;
			if (pauseOnHover && hovering) return false;
			if (focused) return false;
			return true;
		}

		function stopAuto() {
			if (timer !== undefined) window.clearInterval(timer);
			timer = undefined;
		}

		function startAuto() {
			stopAuto();
			if (!canRun()) return;
			timer = window.setInterval(() => goTo(index + 1), interval);
		}

		const refreshAuto = () => (canRun() ? startAuto() : stopAuto());

		function syncToggle() {
			if (!toggle) return;
			const paused = manuallyPaused;
			toggle.setAttribute("aria-pressed", paused ? "true" : "false");
			if (toggleLabel) toggleLabel.textContent = paused ? playLabel : pauseLabel;
		}

		if (autoplayAllowed() && toggle) {
			/* Only revealed once there is a controller behind it. A control that
			   cannot do anything is worse than no control. */
			toggle.hidden = false;
			syncToggle();
			toggle.addEventListener(
				"click",
				() => {
					manuallyPaused = !manuallyPaused;
					syncToggle();
					refreshAuto();
				},
				{ signal },
			);
		}

		if (autoplayAllowed()) {
			if (pauseOnHover) {
				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 });
			reduced.addEventListener("change", refreshAuto, { signal });
		}

		/* Initial exposure, in case the markup and the controller disagree. */
		groups.forEach((group, i) => expose(group, i === index));

		refreshAuto();

		function destroy() {
			stopAuto();
			window.clearTimeout(settleTimer);
			groups.forEach((group, i) => {
				group.dataset.slcState = i === 0 ? "active" : "idle";
				expose(group, i === 0);
			});
			root.removeAttribute("data-slc-instant");
			controller.abort();
			delete root.dataset.slcReady;
		}

		/*
		 * Astro replaces the document on a client-side navigation. Stop the
		 * timer, return every group to the state it was served in, detach every
		 * listener, and clear the ready flag so a re-used element can boot again.
		 */
		document.addEventListener("astro:before-swap", destroy, { once: true, signal });

		return { destroy };
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-slc-root]").forEach(initStaggeredLogoCloud);

	/* 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>