Component librarylocal catalog
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

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

Use this component

Three ways to get Staggered logo cloud 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
“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.

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: Staggered logo cloud
Library page: https://astro.baysixmedia.com/components/staggered-logo-cloud

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 Staggered logo cloud from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/staggered-logo-cloud

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

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

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

--- VERIFIED IN THE COMPONENT LIBRARY ---
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.

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>