Component library
Back to catalog

Cargo logo marquee

Six original partner wordmarks drifting slowly across a band, over a navy-tinted port photo or on light grey under a heading set between hairlines. Pure CSS; pauses on hover, focus or its toggle; static under reduced motion.

logospartnersmarqueebandsocial prooflogistics

Open preview in a new tab

Use this component

Three ways to get Cargo logo marquee 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 2 files 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 Cargo logo marquee from my Astro Component Library to this Astro project.

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

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 2 files this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Cargo logo marquee"
Library page: https://astro.baysixmedia.com/components/cargo-logo-marquee/
Files: 2 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/CargoLogoMarquee.astro =====
---
/**
 * CargoLogoMarquee
 *
 * A band of partner wordmarks drifting slowly right to left, one full loop
 * of the list every ~92s.
 *
 * - `tone: "photo"` (default): a 200px band over a port photo under a navy
 *   overlay, with white and orange marks.
 * - `tone: "light"`: a light grey band with navy and orange marks, and an
 *   optional heading line above it set between two hairlines.
 *
 * Marks come only from `logos` (with no logos it renders nothing); six simple
 * ship / crane / plane / rail / truck / globe glyphs are drawn inline. The marquee is
 * pure CSS: the list is rendered twice and the track slides by exactly one
 * copy, so the loop is seamless; the copy is aria-hidden. It pauses on hover,
 * while anything inside has focus, and while its pause toggle is on; it never
 * runs under prefers-reduced-motion. No script.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export type CargoGlyph = "ship" | "crane" | "plane" | "rail" | "truck" | "globe";

export interface CargoLogo {
	/** First word of the wordmark, in the base colour. */
	name: string;
	/** Second word, in orange. */
	accent?: string;
	/** Tiny line under the wordmark. */
	tagline?: string;
	glyph?: CargoGlyph;
	/** Optional link for the mark. */
	href?: string;
}

export interface CargoLogoMarqueeProps {
	tone?: "photo" | "light";
	/** Light tone only: the line above the band. `""` drops it. */
	heading?: string;
	logos?: CargoLogo[];
	/** Photo tone background. `src: ""` = plain navy band. */
	image?: { src?: string; alt?: string };
	/** Seconds for one full loop of the list. */
	duration?: number;
	/** A small pause toggle in the band's corner (WCAG 2.2.2). */
	pauseToggle?: boolean;
	/** Accessible name for the list of marks. */
	label?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends CargoLogoMarqueeProps {}

const {
	tone = "photo",
	heading = "",
	logos: rawLogos = [],
	image: imageProp,
	duration = 92,
	pauseToggle = true,
	label = "Partners",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

/* No photo by default: the photo tone falls back to its plain navy band. */
const image = { src: "", alt: "", ...(imageProp ?? {}) };
/* A mark without a name is dropped. No marks, no band: the heading alone would be a claim. */
const logos = rawLogos.filter((logo) => logo?.name);
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const uid = id ?? `clm-${Math.random().toString(36).slice(2, 8)}`;
const copies = [false, true];
const seconds = Math.max(5, Number(duration) || 92);
---

{logos.length > 0 && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section
	class:list={["clm", `clm--${tone}`, className]}
	id={id}
	style={`--clm-duration: ${seconds}s;`}
	aria-label={label}
	data-clm-root
>
	{
		tone === "light" && heading && (
			<div class="clm-heading-row">
				<span class="clm-hairline" aria-hidden="true" />
				<p class="clm-heading">{heading}</p>
				<span class="clm-hairline" aria-hidden="true" />
			</div>
		)
	}
	<div class="clm-band">
		{
			tone === "photo" && image.src && (
				<img class="clm-bg" src={image.src} alt={image.alt} width="2000" height="280" loading="lazy" decoding="async" />
			)
		}
		{
			pauseToggle && (
				<>
					<input class="clm-toggle" type="checkbox" id={`${uid}-pause`} />
					<label class="clm-toggle-label" for={`${uid}-pause`}>
						<span class="clm-sr">Pause the moving logos</span>
						<svg class="clm-i-pause" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
							<path d="M3 2h2v8H3zM7 2h2v8H7z" fill="currentColor" />
						</svg>
						<svg class="clm-i-play" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
							<path d="M3 1.5v9l7.5-4.5z" fill="currentColor" />
						</svg>
					</label>
				</>
			)
		}
		<div class="clm-viewport">
			<div class="clm-track">
				{
					copies.map((isCopy) => (
						<ul class="clm-group" role="list" aria-hidden={isCopy ? "true" : undefined}>
							{logos.map((logo) => {
								const inner = (
									<>
										<svg class="clm-glyph" viewBox="0 0 80 44" width="80" height="44" aria-hidden="true" focusable="false">
											{logo.glyph === "ship" && (
												<g>
													<rect class="a" x="18" y="12" width="10" height="10" rx="1" />
													<rect class="a" x="30" y="12" width="10" height="10" rx="1" />
													<rect class="a" x="42" y="6" width="10" height="16" rx="1" />
													<path class="p" d="M56 6h8v18h-8z" />
													<path class="p" d="M4 25h72l-9 13H15z" />
													<path class="s" d="M8 41c5 0 5-2 10-2s5 2 10 2 5-2 10-2 5 2 10 2 5-2 10-2 5 2 10 2" />
												</g>
											)}
											{logo.glyph === "crane" && (
												<g>
													<path class="s" d="M14 42V6h6v36M14 12h58M20 6l10 6M58 12v10" />
													<rect class="a" x="48" y="22" width="20" height="10" rx="1" />
													<path class="p" d="M4 38h34v4H4z" />
													<rect class="a" x="40" y="34" width="14" height="8" rx="1" />
													<rect class="p" x="56" y="34" width="16" height="8" rx="1" />
												</g>
											)}
											{logo.glyph === "plane" && (
												<g>
													<path class="a" d="M8 32 70 12c4-1 7 0 7 2s-3 4-6 5L16 36z" />
													<path class="p" d="M36 22 28 4h6l18 14zM40 26l-4 14h6l12-18zM14 32 8 22h4l10 8z" />
												</g>
											)}
											{logo.glyph === "rail" && (
												<g>
													<path class="a" d="M6 10h50l16 12v12H6z" />
													<rect class="p" x="12" y="15" width="8" height="7" rx="1" />
													<rect class="p" x="24" y="15" width="8" height="7" rx="1" />
													<rect class="p" x="36" y="15" width="8" height="7" rx="1" />
													<path class="p" d="M56 15h4l8 7H56z" />
													<circle class="p" cx="16" cy="37" r="4" />
													<circle class="p" cx="30" cy="37" r="4" />
													<circle class="p" cx="58" cy="37" r="4" />
													<path class="s" d="M2 42h76" />
												</g>
											)}
											{logo.glyph === "truck" && (
												<g>
													<rect class="a" x="4" y="8" width="46" height="24" rx="2" />
													<path class="p" d="M52 14h14l10 11v7H52z" />
													<path class="bg" d="M56 17h8l5 7H56z" />
													<circle class="p" cx="16" cy="36" r="5" />
													<circle class="p" cx="38" cy="36" r="5" />
													<circle class="p" cx="64" cy="36" r="5" />
												</g>
											)}
											{logo.glyph === "globe" && (
												<g>
													<circle class="s" cx="40" cy="22" r="18" />
													<ellipse class="s" cx="40" cy="22" rx="8" ry="18" />
													<path class="s" d="M22 22h36" />
													<path class="a" d="M6 30c16 10 52 10 68-8l2 3C60 44 20 42 6 30z" />
												</g>
											)}
										</svg>
										<span class="clm-name">
											{logo.name}
											{logo.accent && <span class="clm-accent">{logo.accent}</span>}
										</span>
										{logo.tagline && <span class="clm-tag">{logo.tagline}</span>}
									</>
								);
								return (
									<li class="clm-item">
										{logo.href ? (
											<a class="clm-logo" href={safeHref(logo.href)} tabindex={isCopy ? "-1" : undefined}>
												{inner}
											</a>
										) : (
											<span class="clm-logo">{inner}</span>
										)}
									</li>
								);
							})}
						</ul>
					))
				}
			</div>
		</div>
	</div>
</section>
</>
)}

<style>
	.clm {
		--clm-navy: var(--tg-navy, #020e28);
		--clm-orange: var(--tg-orange, #fd5523);
		--clm-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
		--clm-gap: 64px;

		box-sizing: border-box;
		font-family: var(--clm-display);
		overflow-x: clip;
	}

	.clm *,
	.clm *::before,
	.clm *::after {
		box-sizing: border-box;
	}

	.clm--photo {
		--clm-ink: #fff;
		background: var(--clm-navy);
	}

	.clm--light {
		--clm-ink: var(--clm-navy);
		display: flex;
		flex-direction: column;
		gap: 40px;
		background: #fff;
	}

	/* ---- light tone heading line ---- */
	.clm-heading-row {
		display: flex;
		justify-content: center;
		align-items: center;
		gap: 16px;
		width: 100%;
		max-width: 1293px;
		margin: 0 auto;
		padding: 0 20px;
	}

	.clm-hairline {
		display: none;
		flex: 1 1 0;
		max-width: 300px;
		height: 1px;
		background: #d8dde1;
	}

	.clm .clm-heading {
		margin: 0;
		font: 500 18px/1.5 var(--clm-display);
		text-align: center;
		color: var(--clm-navy);
	}

	/* ---- band ---- */
	.clm-band {
		position: relative;
		display: flex;
		align-items: center;
		min-height: 136px;
		padding: 28px 0;
		isolation: isolate;
	}

	.clm--light .clm-band {
		padding: 40px 0;
		background: #f4f4f4;
	}

	.clm-bg {
		position: absolute;
		inset: 0;
		z-index: -2;
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* Navy overlay over the photo. */
	.clm--photo .clm-band::before {
		content: "";
		position: absolute;
		inset: 0;
		z-index: -1;
		background: rgb(2 14 40 / 0.86);
	}

	.clm-viewport {
		width: 100%;
		overflow: hidden;
		container-type: inline-size;
	}

	.clm-track {
		display: flex;
		width: max-content;
		animation: clm-slide var(--clm-duration, 92s) linear infinite;
	}

	@keyframes clm-slide {
		from {
			transform: translateX(0);
		}
		to {
			transform: translateX(-50%);
		}
	}

	.clm .clm-group {
		display: flex;
		flex: none;
		justify-content: space-around;
		align-items: center;
		gap: var(--clm-gap);
		min-width: 100cqw;
		margin: 0;
		padding: 0 var(--clm-gap) 0 0;
		list-style: none;
	}

	.clm-item {
		flex: none;
	}

	.clm-logo {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 4px;
		height: 80px;
		justify-content: center;
		color: var(--clm-ink);
		text-decoration: none;
		white-space: nowrap;
	}

	a.clm-logo:focus-visible {
		outline: 2px solid var(--clm-orange);
		outline-offset: 4px;
		border-radius: 4px;
	}

	.clm-glyph {
		display: block;
		width: 64px;
		height: auto;
		overflow: visible;
	}

	.clm-glyph .p {
		fill: var(--clm-ink);
	}

	.clm-glyph .a {
		fill: var(--clm-orange);
	}

	.clm-glyph .s {
		fill: none;
		stroke: var(--clm-ink);
		stroke-width: 3;
		stroke-linecap: round;
		stroke-linejoin: round;
	}

	.clm-glyph .bg {
		fill: var(--clm-orange);
		opacity: 0.35;
	}

	.clm-name {
		font: 700 15px/1 var(--clm-display);
		letter-spacing: 0.02em;
		text-transform: uppercase;
	}

	.clm-accent {
		color: var(--clm-orange);
	}

	.clm-tag {
		font: 500 6.5px/1 var(--clm-display);
		letter-spacing: 0.24em;
		text-transform: uppercase;
		opacity: 0.7;
	}

	/* ---- pause: hover, focus inside, or the toggle ---- */
	.clm-band:hover .clm-track,
	.clm-band:focus-within .clm-track,
	.clm-toggle:checked ~ .clm-viewport .clm-track {
		animation-play-state: paused;
	}

	.clm-toggle {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: 0;
		opacity: 0;
		pointer-events: none;
	}

	.clm-toggle-label {
		position: absolute;
		right: 8px;
		bottom: 4px;
		z-index: 1;
		display: grid;
		place-items: center;
		width: 44px;
		height: 44px;
		border-radius: 50%;
		color: var(--clm-ink);
		opacity: 0.35;
		cursor: pointer;
		transition: opacity 0.3s ease-out;
	}

	.clm-toggle-label::before {
		content: "";
		position: absolute;
		inset: 10px;
		border: 1px solid currentColor;
		border-radius: 50%;
	}

	.clm-band:hover .clm-toggle-label,
	.clm-toggle:focus-visible + .clm-toggle-label {
		opacity: 1;
	}

	.clm-toggle:focus-visible + .clm-toggle-label {
		outline: 2px solid var(--clm-orange);
		outline-offset: -2px;
	}

	.clm-i-play,
	.clm-toggle:checked + .clm-toggle-label .clm-i-pause {
		display: none;
	}

	.clm-toggle:checked + .clm-toggle-label .clm-i-play {
		display: block;
	}

	.clm-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip: rect(0 0 0 0);
		white-space: nowrap;
		border: 0;
	}

	@media (min-width: 810px) {
		.clm {
			--clm-gap: 120px;
		}

		.clm--light {
			gap: 60px;
		}

		.clm-hairline {
			display: block;
		}

		.clm .clm-heading {
			font-size: 20px;
			line-height: 30px;
		}

		.clm-band {
			min-height: 200px;
			padding: 60px 0;
		}

		.clm--light .clm-band {
			padding: 60px 0;
		}

		.clm-glyph {
			width: 76px;
		}

		.clm-name {
			font-size: 17px;
		}
	}

	@media (min-width: 1200px) {
		.clm {
			--clm-gap: 171px;
		}
	}

	/* Static under reduced motion; the copy is hidden so nothing repeats. */
	@media (prefers-reduced-motion: reduce) {
		.clm-track {
			animation: none;
			width: 100%;
		}

		.clm .clm-group {
			flex-wrap: wrap;
			justify-content: center;
			row-gap: 24px;
			min-width: 0;
			width: 100%;
			padding: 0 20px;
			gap: 24px 48px;
		}

		.clm .clm-group[aria-hidden="true"],
		.clm-toggle,
		.clm-toggle-label {
			display: none;
		}
	}

	@media (forced-colors: active) {
		.clm-glyph .p,
		.clm-glyph .a,
		.clm-glyph .bg {
			fill: CanvasText;
		}

		.clm-glyph .s {
			stroke: CanvasText;
		}
	}
</style>

===== END src/components/CargoLogoMarquee.astro =====

===== FILE (supporting): src/components/CargoLogoMarquee.md =====
# Cargo logo marquee

A band of partner wordmarks drifting slowly right to left, one full loop of
the list every ~92 seconds. Two tones:

- **`tone="photo"`** (default): a 200px band over a port photo under a navy
  overlay, white and orange marks. Home and about pages.
- **`tone="light"`**: a light grey band with navy and orange marks, and an
  optional heading line above it set between two hairlines. Contact page.

Catalog page: `/components/cargo-logo-marquee`

Part of the Northpass Logistics family (`--tg-*` tokens).

## Files required

**`CargoLogoMarquee.astro`**: one file. No imports, no packages, **no
script**. The marks are drawn inline.

`CargoLogoMarquee.md`: this file.

`public/cargo-logo-marquee/port-aerial.webp`: optional, only if you pass it
as `image` (the catalog demo does). No partner marks ship as defaults.

## Basic usage

```astro
---
import CargoLogoMarquee from "../components/CargoLogoMarquee.astro";
---

<!-- home / about -->
<CargoLogoMarquee
  logos={[
    { name: "Harbor", accent: "Line", tagline: "Ocean Carriers", glyph: "ship" },
    { name: "Sky", accent: "run", tagline: "Air Freight", glyph: "plane" },
  ]}
  image={{ src: "/cargo-logo-marquee/port-aerial.webp", alt: "" }}
/>

<!-- contact -->
<CargoLogoMarquee
  tone="light"
  heading="Your partners' line here"
  logos={partners}
/>
```

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `tone` | `"photo" \| "light"?` | `"photo"` | See above. |
| `heading` | `string?` | `""` | Light tone only. `""` drops the line. The default is none, so no customer count is claimed. |
| `logos` | `CargoLogo[]?` | `[]` | `{ name, accent?, tagline?, glyph?, href? }`. A mark without `name` is dropped. With no logos the component renders nothing (the heading never renders alone). |
| `image` | `{ src?, alt? }?` | none | Photo tone background. None or `src: ""` = plain navy band. The catalog demo passes `port-aerial.webp`. |
| `duration` | `number?` | `92` | Seconds for one full loop of the list (min 5). |
| `pauseToggle` | `boolean?` | `true` | Small pause / play toggle in the band's corner. |
| `label` | `string?` | `"Partners"` | Accessible name of the section. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` to skip. |
| `class`, `id` | `string?` | | `id` also seeds the toggle id. |

`glyph` is one of `"ship"`, `"crane"`, `"plane"`, `"rail"`, `"truck"`,
`"globe"`. The wordmark is `name` in the base colour followed by `accent` in
orange, uppercase, with `tagline` in tiny tracked capitals under it.

## Behaviour

**Marquee.** The list is rendered twice inside a `max-content` track, which
slides from `translateX(0)` to `translateX(-50%)`: exactly one copy, so the
loop is seamless. Each copy is at least as wide as the band (`100cqw`, the
viewport is a size container), so very wide screens never see a gap. Gaps
between marks: 171px at desktop (measured), 120px on tablet, 64px on a phone.

**Pausing.** The track pauses on hover, while anything in the band has
focus, and while the pause toggle is checked. The toggle is a visually
hidden native checkbox with a 44px round label (pause / play glyph), faint at
rest and full strength on hover or focus. No script.

**Reduced motion.** It never moves: the duplicate copy and the toggle are
hidden and the marks wrap, centred.

**Photo tone.** `object-fit: cover` photo, `rgba(2,14,40,.86)` overlay,
60px vertical padding at 810px and up (200px band), 28px on a phone (136px,
as measured).

**Light tone.** White section, heading Outfit 500 20/30 navy between two
`#d8dde1` hairlines (up to 300px each, hidden on a phone), 60px gap, then the
`#f4f4f4` band with 60px padding.

No hover states on the marks themselves (none measured).

## Accessibility

- The section is a labelled region; the first copy of the list is read, the
  second is `aria-hidden` and any links in it are `tabindex="-1"`.
- Each mark's name is real text; the glyphs are `aria-hidden`.
- The pause toggle satisfies WCAG 2.2.2 for keyboard and touch users, who
  cannot hover.

## Images and credits

| File | Size | Source |
| :--- | :--- | :--- |
| `port-aerial.webp` | 2000 × 280 | Unsplash `kyCNGGKCvyw`, Unsplash License (free commercial use, no attribution required) |

The catalog demo's six partner marks and their names are original and
invented; they live in the registry's demo props, not the component.

## Approximations

- **Pause on hover / focus-within** is not on the reference; it is added for
  accessibility, as is the **pause toggle**, which the reference does not
  have. Pass `pauseToggle={false}` to drop it.
- **Loop speed** is set from the measured ~92s per loop of the list.
  Because the logos here differ in width from the reference's, the pixel
  speed differs slightly.
- **The light band on a phone** wraps its six logos into a static 3 × 2 grid
  on the reference; here it keeps scrolling like the desktop band.
- The reference's photo is pre-tinted; ours is a plain aerial photo, so the
  navy overlay is added in CSS (86%, estimated).

===== END src/components/CargoLogoMarquee.md =====

===== FILE (required): public/cargo-logo-marquee/port-aerial.webp =====
[binary asset — download https://astro.baysixmedia.com/cargo-logo-marquee/port-aerial.webp and save it to public/cargo-logo-marquee/port-aerial.webp]

--- 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: not applicable — this component ships no script.

--- 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
“Cargo logo marquee” from my Astro Component Library:
https://astro.baysixmedia.com/components/cargo-logo-marquee/

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
Static
Profile
core only
Installation files
2 required + 1 supporting
Verified
Never
Client navigation
Not applicable — ships no script
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 fixture

src/components/library/CargoLogoMarquee.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/cargo-logo-marquee/port-aerial.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * CargoLogoMarquee
 *
 * A band of partner wordmarks drifting slowly right to left, one full loop
 * of the list every ~92s.
 *
 * - `tone: "photo"` (default): a 200px band over a port photo under a navy
 *   overlay, with white and orange marks.
 * - `tone: "light"`: a light grey band with navy and orange marks, and an
 *   optional heading line above it set between two hairlines.
 *
 * Marks come only from `logos` (with no logos it renders nothing); six simple
 * ship / crane / plane / rail / truck / globe glyphs are drawn inline. The marquee is
 * pure CSS: the list is rendered twice and the track slides by exactly one
 * copy, so the loop is seamless; the copy is aria-hidden. It pauses on hover,
 * while anything inside has focus, and while its pause toggle is on; it never
 * runs under prefers-reduced-motion. No script.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export type CargoGlyph = "ship" | "crane" | "plane" | "rail" | "truck" | "globe";

export interface CargoLogo {
	/** First word of the wordmark, in the base colour. */
	name: string;
	/** Second word, in orange. */
	accent?: string;
	/** Tiny line under the wordmark. */
	tagline?: string;
	glyph?: CargoGlyph;
	/** Optional link for the mark. */
	href?: string;
}

export interface CargoLogoMarqueeProps {
	tone?: "photo" | "light";
	/** Light tone only: the line above the band. `""` drops it. */
	heading?: string;
	logos?: CargoLogo[];
	/** Photo tone background. `src: ""` = plain navy band. */
	image?: { src?: string; alt?: string };
	/** Seconds for one full loop of the list. */
	duration?: number;
	/** A small pause toggle in the band's corner (WCAG 2.2.2). */
	pauseToggle?: boolean;
	/** Accessible name for the list of marks. */
	label?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends CargoLogoMarqueeProps {}

const {
	tone = "photo",
	heading = "",
	logos: rawLogos = [],
	image: imageProp,
	duration = 92,
	pauseToggle = true,
	label = "Partners",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

/* No photo by default: the photo tone falls back to its plain navy band. */
const image = { src: "", alt: "", ...(imageProp ?? {}) };
/* A mark without a name is dropped. No marks, no band: the heading alone would be a claim. */
const logos = rawLogos.filter((logo) => logo?.name);
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const uid = id ?? `clm-${Math.random().toString(36).slice(2, 8)}`;
const copies = [false, true];
const seconds = Math.max(5, Number(duration) || 92);
---

{logos.length > 0 && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section
	class:list={["clm", `clm--${tone}`, className]}
	id={id}
	style={`--clm-duration: ${seconds}s;`}
	aria-label={label}
	data-clm-root
>
	{
		tone === "light" && heading && (
			<div class="clm-heading-row">
				<span class="clm-hairline" aria-hidden="true" />
				<p class="clm-heading">{heading}</p>
				<span class="clm-hairline" aria-hidden="true" />
			</div>
		)
	}
	<div class="clm-band">
		{
			tone === "photo" && image.src && (
				<img class="clm-bg" src={image.src} alt={image.alt} width="2000" height="280" loading="lazy" decoding="async" />
			)
		}
		{
			pauseToggle && (
				<>
					<input class="clm-toggle" type="checkbox" id={`${uid}-pause`} />
					<label class="clm-toggle-label" for={`${uid}-pause`}>
						<span class="clm-sr">Pause the moving logos</span>
						<svg class="clm-i-pause" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
							<path d="M3 2h2v8H3zM7 2h2v8H7z" fill="currentColor" />
						</svg>
						<svg class="clm-i-play" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
							<path d="M3 1.5v9l7.5-4.5z" fill="currentColor" />
						</svg>
					</label>
				</>
			)
		}
		<div class="clm-viewport">
			<div class="clm-track">
				{
					copies.map((isCopy) => (
						<ul class="clm-group" role="list" aria-hidden={isCopy ? "true" : undefined}>
							{logos.map((logo) => {
								const inner = (
									<>
										<svg class="clm-glyph" viewBox="0 0 80 44" width="80" height="44" aria-hidden="true" focusable="false">
											{logo.glyph === "ship" && (
												<g>
													<rect class="a" x="18" y="12" width="10" height="10" rx="1" />
													<rect class="a" x="30" y="12" width="10" height="10" rx="1" />
													<rect class="a" x="42" y="6" width="10" height="16" rx="1" />
													<path class="p" d="M56 6h8v18h-8z" />
													<path class="p" d="M4 25h72l-9 13H15z" />
													<path class="s" d="M8 41c5 0 5-2 10-2s5 2 10 2 5-2 10-2 5 2 10 2 5-2 10-2 5 2 10 2" />
												</g>
											)}
											{logo.glyph === "crane" && (
												<g>
													<path class="s" d="M14 42V6h6v36M14 12h58M20 6l10 6M58 12v10" />
													<rect class="a" x="48" y="22" width="20" height="10" rx="1" />
													<path class="p" d="M4 38h34v4H4z" />
													<rect class="a" x="40" y="34" width="14" height="8" rx="1" />
													<rect class="p" x="56" y="34" width="16" height="8" rx="1" />
												</g>
											)}
											{logo.glyph === "plane" && (
												<g>
													<path class="a" d="M8 32 70 12c4-1 7 0 7 2s-3 4-6 5L16 36z" />
													<path class="p" d="M36 22 28 4h6l18 14zM40 26l-4 14h6l12-18zM14 32 8 22h4l10 8z" />
												</g>
											)}
											{logo.glyph === "rail" && (
												<g>
													<path class="a" d="M6 10h50l16 12v12H6z" />
													<rect class="p" x="12" y="15" width="8" height="7" rx="1" />
													<rect class="p" x="24" y="15" width="8" height="7" rx="1" />
													<rect class="p" x="36" y="15" width="8" height="7" rx="1" />
													<path class="p" d="M56 15h4l8 7H56z" />
													<circle class="p" cx="16" cy="37" r="4" />
													<circle class="p" cx="30" cy="37" r="4" />
													<circle class="p" cx="58" cy="37" r="4" />
													<path class="s" d="M2 42h76" />
												</g>
											)}
											{logo.glyph === "truck" && (
												<g>
													<rect class="a" x="4" y="8" width="46" height="24" rx="2" />
													<path class="p" d="M52 14h14l10 11v7H52z" />
													<path class="bg" d="M56 17h8l5 7H56z" />
													<circle class="p" cx="16" cy="36" r="5" />
													<circle class="p" cx="38" cy="36" r="5" />
													<circle class="p" cx="64" cy="36" r="5" />
												</g>
											)}
											{logo.glyph === "globe" && (
												<g>
													<circle class="s" cx="40" cy="22" r="18" />
													<ellipse class="s" cx="40" cy="22" rx="8" ry="18" />
													<path class="s" d="M22 22h36" />
													<path class="a" d="M6 30c16 10 52 10 68-8l2 3C60 44 20 42 6 30z" />
												</g>
											)}
										</svg>
										<span class="clm-name">
											{logo.name}
											{logo.accent && <span class="clm-accent">{logo.accent}</span>}
										</span>
										{logo.tagline && <span class="clm-tag">{logo.tagline}</span>}
									</>
								);
								return (
									<li class="clm-item">
										{logo.href ? (
											<a class="clm-logo" href={safeHref(logo.href)} tabindex={isCopy ? "-1" : undefined}>
												{inner}
											</a>
										) : (
											<span class="clm-logo">{inner}</span>
										)}
									</li>
								);
							})}
						</ul>
					))
				}
			</div>
		</div>
	</div>
</section>
</>
)}

<style>
	.clm {
		--clm-navy: var(--tg-navy, #020e28);
		--clm-orange: var(--tg-orange, #fd5523);
		--clm-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
		--clm-gap: 64px;

		box-sizing: border-box;
		font-family: var(--clm-display);
		overflow-x: clip;
	}

	.clm *,
	.clm *::before,
	.clm *::after {
		box-sizing: border-box;
	}

	.clm--photo {
		--clm-ink: #fff;
		background: var(--clm-navy);
	}

	.clm--light {
		--clm-ink: var(--clm-navy);
		display: flex;
		flex-direction: column;
		gap: 40px;
		background: #fff;
	}

	/* ---- light tone heading line ---- */
	.clm-heading-row {
		display: flex;
		justify-content: center;
		align-items: center;
		gap: 16px;
		width: 100%;
		max-width: 1293px;
		margin: 0 auto;
		padding: 0 20px;
	}

	.clm-hairline {
		display: none;
		flex: 1 1 0;
		max-width: 300px;
		height: 1px;
		background: #d8dde1;
	}

	.clm .clm-heading {
		margin: 0;
		font: 500 18px/1.5 var(--clm-display);
		text-align: center;
		color: var(--clm-navy);
	}

	/* ---- band ---- */
	.clm-band {
		position: relative;
		display: flex;
		align-items: center;
		min-height: 136px;
		padding: 28px 0;
		isolation: isolate;
	}

	.clm--light .clm-band {
		padding: 40px 0;
		background: #f4f4f4;
	}

	.clm-bg {
		position: absolute;
		inset: 0;
		z-index: -2;
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* Navy overlay over the photo. */
	.clm--photo .clm-band::before {
		content: "";
		position: absolute;
		inset: 0;
		z-index: -1;
		background: rgb(2 14 40 / 0.86);
	}

	.clm-viewport {
		width: 100%;
		overflow: hidden;
		container-type: inline-size;
	}

	.clm-track {
		display: flex;
		width: max-content;
		animation: clm-slide var(--clm-duration, 92s) linear infinite;
	}

	@keyframes clm-slide {
		from {
			transform: translateX(0);
		}
		to {
			transform: translateX(-50%);
		}
	}

	.clm .clm-group {
		display: flex;
		flex: none;
		justify-content: space-around;
		align-items: center;
		gap: var(--clm-gap);
		min-width: 100cqw;
		margin: 0;
		padding: 0 var(--clm-gap) 0 0;
		list-style: none;
	}

	.clm-item {
		flex: none;
	}

	.clm-logo {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 4px;
		height: 80px;
		justify-content: center;
		color: var(--clm-ink);
		text-decoration: none;
		white-space: nowrap;
	}

	a.clm-logo:focus-visible {
		outline: 2px solid var(--clm-orange);
		outline-offset: 4px;
		border-radius: 4px;
	}

	.clm-glyph {
		display: block;
		width: 64px;
		height: auto;
		overflow: visible;
	}

	.clm-glyph .p {
		fill: var(--clm-ink);
	}

	.clm-glyph .a {
		fill: var(--clm-orange);
	}

	.clm-glyph .s {
		fill: none;
		stroke: var(--clm-ink);
		stroke-width: 3;
		stroke-linecap: round;
		stroke-linejoin: round;
	}

	.clm-glyph .bg {
		fill: var(--clm-orange);
		opacity: 0.35;
	}

	.clm-name {
		font: 700 15px/1 var(--clm-display);
		letter-spacing: 0.02em;
		text-transform: uppercase;
	}

	.clm-accent {
		color: var(--clm-orange);
	}

	.clm-tag {
		font: 500 6.5px/1 var(--clm-display);
		letter-spacing: 0.24em;
		text-transform: uppercase;
		opacity: 0.7;
	}

	/* ---- pause: hover, focus inside, or the toggle ---- */
	.clm-band:hover .clm-track,
	.clm-band:focus-within .clm-track,
	.clm-toggle:checked ~ .clm-viewport .clm-track {
		animation-play-state: paused;
	}

	.clm-toggle {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: 0;
		opacity: 0;
		pointer-events: none;
	}

	.clm-toggle-label {
		position: absolute;
		right: 8px;
		bottom: 4px;
		z-index: 1;
		display: grid;
		place-items: center;
		width: 44px;
		height: 44px;
		border-radius: 50%;
		color: var(--clm-ink);
		opacity: 0.35;
		cursor: pointer;
		transition: opacity 0.3s ease-out;
	}

	.clm-toggle-label::before {
		content: "";
		position: absolute;
		inset: 10px;
		border: 1px solid currentColor;
		border-radius: 50%;
	}

	.clm-band:hover .clm-toggle-label,
	.clm-toggle:focus-visible + .clm-toggle-label {
		opacity: 1;
	}

	.clm-toggle:focus-visible + .clm-toggle-label {
		outline: 2px solid var(--clm-orange);
		outline-offset: -2px;
	}

	.clm-i-play,
	.clm-toggle:checked + .clm-toggle-label .clm-i-pause {
		display: none;
	}

	.clm-toggle:checked + .clm-toggle-label .clm-i-play {
		display: block;
	}

	.clm-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip: rect(0 0 0 0);
		white-space: nowrap;
		border: 0;
	}

	@media (min-width: 810px) {
		.clm {
			--clm-gap: 120px;
		}

		.clm--light {
			gap: 60px;
		}

		.clm-hairline {
			display: block;
		}

		.clm .clm-heading {
			font-size: 20px;
			line-height: 30px;
		}

		.clm-band {
			min-height: 200px;
			padding: 60px 0;
		}

		.clm--light .clm-band {
			padding: 60px 0;
		}

		.clm-glyph {
			width: 76px;
		}

		.clm-name {
			font-size: 17px;
		}
	}

	@media (min-width: 1200px) {
		.clm {
			--clm-gap: 171px;
		}
	}

	/* Static under reduced motion; the copy is hidden so nothing repeats. */
	@media (prefers-reduced-motion: reduce) {
		.clm-track {
			animation: none;
			width: 100%;
		}

		.clm .clm-group {
			flex-wrap: wrap;
			justify-content: center;
			row-gap: 24px;
			min-width: 0;
			width: 100%;
			padding: 0 20px;
			gap: 24px 48px;
		}

		.clm .clm-group[aria-hidden="true"],
		.clm-toggle,
		.clm-toggle-label {
			display: none;
		}
	}

	@media (forced-colors: active) {
		.clm-glyph .p,
		.clm-glyph .a,
		.clm-glyph .bg {
			fill: CanvasText;
		}

		.clm-glyph .s {
			stroke: CanvasText;
		}
	}
</style>