Component library
Back to catalog

Color service grid

A light services section with a big centred heading and a 3×2 grid of tall rounded cards in six colours: icon tile, huge faded index number, title, text and an "Explore service" link. The first card carries a photo behind a gradient. Each whole card is one link.

servicescardsgridcolourhoverstretched-linkhalcyon

Open preview in a new tab

Use this component

Three ways to get Color service grid 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 Color service grid from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/color-service-grid/

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: "Color service grid"
Library page: https://astro.baysixmedia.com/components/color-service-grid/
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/ColorServiceGrid.astro =====
---
/**
 * ColorServiceGrid
 *
 * A light services section: lime-dot eyebrow, a big centred heading, a short
 * paragraph, then a grid of tall rounded cards, each in its own colour. Every
 * card has an icon tile, a huge faded index number in the top-right corner, a
 * title, a line of text and an "Explore service" link along the bottom. Any
 * card can carry a photo, which sits in its lower-right area behind a
 * gradient so the copy stays legible.
 *
 * The whole card is one link target (a stretched link). Hover lifts the card
 * and gives it a soft shadow in its own hue.
 *
 * Three across from 1200px, two from 810px, one below. Static: no script.
 *
 * Reads --nv-* design tokens from any ancestor, with fallbacks.
 */

export type ServiceTone = "ink" | "purple" | "orange" | "aqua" | "lime" | "green";
export type ServiceIcon = "truck" | "ship" | "plane" | "warehouse" | "bike" | "shield";

export interface ServiceCard {
	title: string;
	text?: string;
	href: string;
	/** The card colour. Picks background, text, number, tile and shadow hue together. */
	tone?: ServiceTone;
	icon?: ServiceIcon;
	/** Link label. Defaults to the section's `ctaLabel`. */
	ctaLabel?: string;
	/**
	 * Photo in the card's lower-right area. `src: ""` renders the labelled
	 * placeholder instead. Omit for a plain colour card.
	 */
	image?: { src?: string; alt?: string };
}

export interface ColorServiceGridProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	intro?: string;
	cards?: ServiceCard[];
	/** Default link label on every card. */
	ctaLabel?: string;
	/** Show the 01–06 index numbers. */
	numbered?: boolean;
	background?: string;
	fontStylesheet?: string;
	id?: string;
}

interface Props extends ColorServiceGridProps {}

const {
	eyebrow = "",
	heading = "",
	headingLevel = "h2",
	intro = "",
	cards = [],
	ctaLabel = "Explore service",
	numbered = true,
	background = "#eef2eb",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap",
	id,
} = Astro.props;

const Heading = headingLevel;
const CardHeading = headingLevel === "h2" ? "h3" : "h4";
const TONES = new Set(["ink", "purple", "orange", "aqua", "lime", "green"]);
const list = (Array.isArray(cards) ? cards : []).filter((c) => c && c.title && c.href);

/* 24x24, 1.8 stroke, round caps. Drawn for this library. */
const ICONS: Record<string, string> = {
	truck:
		'<path d="M2.5 6.5h11v9h-11z"/><path d="M13.5 9.5h4l3 3v3h-7"/><circle cx="7" cy="17.5" r="1.8"/><circle cx="17" cy="17.5" r="1.8"/>',
	ship:
		'<path d="M3 14.5l1.8 4.2c2.4 1 4.8 1 7.2 0 2.4 1 4.8 1 7.2 0l1.8-4.2-9-3.5z"/><path d="M6 12.3V8h12v4.3"/><path d="M10 8V4.5h4V8"/>',
	plane:
		'<path d="M20.5 3.5c-1-1-2.6-.9-3.6.1l-3.4 3.6-8-2.4-1.9 1.9 6.5 3.8-3.3 3.5-2.6-.5-1.5 1.5 3.4 1.6 1.6 3.4 1.5-1.5-.5-2.6 3.5-3.3 3.8 6.5 1.9-1.9-2.4-8 3.6-3.4c1-1 1.1-2.6.1-3.6z"/>',
	warehouse:
		'<path d="M3 20V9l9-5 9 5v11"/><path d="M7 20v-8h10v8"/><path d="M7 15h10M7 18h10"/>',
	bike:
		'<circle cx="6" cy="16.5" r="3.5"/><circle cx="18" cy="16.5" r="3.5"/><path d="M6 16.5l4-7h5l3 7M10 9.5l2.5 7h-6.5"/><circle cx="14" cy="5" r="1.4"/>',
	shield:
		'<path d="M12 3l7.5 3v5.5c0 4.6-3.2 8.2-7.5 9.5-4.3-1.3-7.5-4.9-7.5-9.5V6z"/><path d="M8.8 12.2l2.3 2.3 4.3-4.6"/>',
};

const uid = id ?? `csg-${Math.random().toString(36).slice(2, 9)}`;
---

{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{list.length > 0 && (
<section class="csg" id={id} style={`--csg-bg: ${background};`} aria-labelledby={heading ? `${uid}-h` : undefined}>
	<div class="csg-inner">
		{
			(eyebrow || heading || intro) && (
				<header class="csg-intro">
					{eyebrow && (
						<p class="csg-eyebrow">
							<span class="csg-dot" aria-hidden="true" />
							{eyebrow}
						</p>
					)}
					{heading && <Heading class="csg-heading" id={`${uid}-h`}>{heading}</Heading>}
					{intro && <p class="csg-lede">{intro}</p>}
				</header>
			)
		}

		{
			list.length > 0 && (
				<ul class="csg-grid" role="list">
					{list.map((card, i) => {
						const tone = card.tone && TONES.has(card.tone) ? card.tone : "ink";
						const icon = card.icon && ICONS[card.icon] ? ICONS[card.icon] : null;
						const img = card.image;
						return (
							<li class:list={["csg-card", `csg-card--${tone}`, { "csg-card--photo": !!img }]}>
								{img && (
									<div class="csg-photo">
										{img.src ? (
											<img src={img.src} alt={img.alt ?? ""} width="720" height="960" loading="lazy" decoding="async" />
										) : (
											<div class="csg-ph" role="img" aria-label={`Image placeholder: ${img.alt || card.title} photo`}>
												<span class="csg-ph-text" aria-hidden="true">
													<span class="csg-ph-label">Image</span>
													<span>{img.alt || `${card.title} photo`}</span>
													<span class="csg-ph-spec">3:4 · 720 × 960 px</span>
												</span>
											</div>
										)}
									</div>
								)}
								{img && <span class="csg-scrim" aria-hidden="true" />}
								{numbered && (
									<span class="csg-num" aria-hidden="true">
										{String(i + 1).padStart(2, "0")}
									</span>
								)}
								<div class="csg-copy">
									{icon && (
										<span class="csg-tile" aria-hidden="true">
											<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false" set:html={icon} />
										</span>
									)}
									<CardHeading class="csg-title">{card.title}</CardHeading>
									{card.text && <p class="csg-text">{card.text}</p>}
								</div>
								<a class="csg-link" href={card.href}>
									<span>{card.ctaLabel ?? ctaLabel}</span>
									<span class="csg-sr">: {card.title}</span>
									<svg class="csg-arrow" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
										<path d="M5 11l6-6M6 5h5v5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
									</svg>
								</a>
							</li>
						);
					})}
				</ul>
			)
		}
	</div>
</section>
)}

<style>
	.csg {
		--csg-ink: var(--nv-ink, #08110f);
		--csg-font: var(--nv-font, Manrope, Inter, ui-sans-serif, system-ui, sans-serif);
		--csg-lime: var(--nv-lime, #c8ff38);
		--csg-ease: cubic-bezier(0.22, 1, 0.36, 1);
		--csg-dur: 0.3s;

		box-sizing: border-box;
		padding: 112px 20px 120px;
		background: var(--csg-bg);
		font-family: var(--csg-font);
		color: var(--csg-ink);
		overflow: clip;
	}

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

	.csg-inner {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 54px;
		max-width: 1160px;
		margin: 0 auto;
	}

	.csg-intro {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 18px;
		max-width: 780px;
		text-align: center;
	}

	.csg-eyebrow {
		display: flex;
		align-items: center;
		gap: 9px;
		margin: 0;
		font: 700 12px/14.4px var(--csg-font);
		letter-spacing: 1.44px;
		text-transform: uppercase;
		color: var(--csg-ink);
	}

	.csg-dot {
		flex: none;
		width: 8px;
		height: 8px;
		border-radius: 50%;
		background: var(--csg-lime);
		box-shadow: 0 0 0 6px rgb(200 255 56 / 0.18);
	}

	.csg-heading {
		max-width: 760px;
		margin: 0;
		font-weight: 700;
		font-size: clamp(44px, 16vw, 62px);
		line-height: 0.95;
		letter-spacing: -0.055em;
		color: var(--csg-ink);
		text-wrap: balance;
	}

	.csg-lede {
		max-width: 610px;
		margin: 0;
		font: 400 17px/27.2px var(--csg-font);
		color: var(--nv-muted, #5b6c65);
		text-wrap: pretty;
	}

	/* The phone reference holds the lede to ~310px, narrower than the cards. */
	@media (max-width: 809px) {
		.csg-lede {
			padding-inline: 20px;
		}
	}

	.csg-grid {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 18px;
		width: 100%;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	/* ---- the card ---- */

	.csg-card {
		/* Tone defaults: ink card. Each tone below overrides these. */
		--csg-card-bg: #08110f;
		--csg-card-fg: #fff;
		--csg-card-muted: rgb(255 255 255 / 0.72);
		--csg-card-num: rgb(255 255 255 / 0.13);
		--csg-card-tile: rgb(200 255 56 / 0.18);
		--csg-card-icon: #c8ff38;
		--csg-card-link: #fff;
		--csg-card-arrow: #c8ff38;
		--csg-card-hue: 8 17 15;

		position: relative;
		display: flex;
		flex-direction: column;
		justify-content: space-between;
		align-items: flex-start;
		gap: 32px;
		min-height: 350px;
		min-width: 0;
		padding: 30px;
		border-radius: 28px;
		background: var(--csg-card-bg);
		color: var(--csg-card-fg);
		overflow: clip;
		isolation: isolate;
		box-shadow: 0 20px 46px 0 rgb(var(--csg-card-hue) / 0);
		transition:
			transform var(--csg-dur) var(--csg-ease),
			box-shadow var(--csg-dur) var(--csg-ease);
	}

	.csg-card--purple {
		--csg-card-bg: #9d8cff;
		--csg-card-hue: 157 140 255;
		--csg-card-tile: rgb(255 255 255 / 0.24);
	}

	.csg-card--orange {
		--csg-card-bg: #ff9a62;
		--csg-card-hue: 255 154 98;
		--csg-card-tile: rgb(255 255 255 / 0.27);
	}

	.csg-card--aqua {
		--csg-card-bg: #73f5df;
		--csg-card-hue: 115 245 223;
		--csg-card-tile: rgb(255 255 255 / 0.3);
	}

	.csg-card--lime {
		--csg-card-bg: #c8ff38;
		--csg-card-hue: 200 255 56;
		--csg-card-tile: rgb(8 17 15 / 0.1);
	}

	.csg-card--purple,
	.csg-card--orange,
	.csg-card--aqua,
	.csg-card--lime {
		--csg-card-fg: #08110f;
		--csg-card-muted: rgb(8 17 15 / 0.74);
		--csg-card-num: rgb(8 17 15 / 0.1);
		--csg-card-icon: #08110f;
		--csg-card-link: #000;
		--csg-card-arrow: #000;
	}

	.csg-card--green {
		--csg-card-bg: #163c33;
		--csg-card-hue: 22 60 51;
		--csg-card-num: rgb(255 255 255 / 0.11);
		--csg-card-tile: rgb(255 154 98 / 0.18);
		--csg-card-icon: #ffb487;
		--csg-card-link: #ffb487;
		--csg-card-arrow: #ffb487;
	}

	.csg-card:hover {
		transform: translateY(-7px) scale(1.01);
		box-shadow: 0 20px 46px 0 rgb(var(--csg-card-hue) / 0.26);
	}

	.csg-photo {
		position: absolute;
		/* Measured on a 375x350 card: 186px in, 89px down, 217x287,
		   running off the right and bottom edges. */
		inset: 89px -28px -26px 49.6%;
		z-index: -2;
		border-top-left-radius: 180px;
		overflow: hidden;
		opacity: 0.78;
	}

	.csg-photo img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.csg-scrim {
		position: absolute;
		inset: 0;
		z-index: -1;
		background: linear-gradient(
			90deg,
			rgb(8 17 15 / 0.96) 0%,
			rgb(8 17 15 / 0.62) 55%,
			rgb(8 17 15 / 0.04) 100%
		);
		pointer-events: none;
	}

	.csg-ph {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		padding: 40px 36px 36px 44px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #1d2a27 0 12px, #243330 12px 24px);
		color: rgb(255 255 255 / 0.7);
	}

	.csg-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 6px;
		font: 500 12px/1.35 var(--csg-font);
	}

	.csg-ph-label {
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.csg-ph-spec {
		font-weight: 400;
		opacity: 0.8;
	}

	.csg-num {
		position: absolute;
		top: 16px;
		right: 25px;
		z-index: -1;
		font: 700 72px/72px var(--csg-font);
		letter-spacing: -5.04px;
		color: var(--csg-card-num);
		pointer-events: none;
		user-select: none;
	}

	.csg-copy {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
		max-width: 315px;
	}

	.csg-tile {
		display: grid;
		place-items: center;
		width: 56px;
		height: 56px;
		border-radius: 18px;
		background: var(--csg-card-tile);
		color: var(--csg-card-icon);
	}

	.csg-tile svg {
		width: 25px;
		height: 25px;
	}

	.csg-title {
		margin: 0;
		font: 700 31px/32.55px var(--csg-font);
		letter-spacing: -1.395px;
		color: var(--csg-card-fg);
	}

	.csg-text {
		margin: 0;
		font: 400 14px/22.12px var(--csg-font);
		color: var(--csg-card-muted);
	}

	/* Stretched link: the ::after covers the whole card, so the card is one
	   target while the accessible name stays on the link itself. */
	.csg-link {
		display: inline-flex;
		align-items: center;
		gap: 8px;
		min-height: 24px;
		font: 700 13px/15.6px var(--csg-font);
		color: var(--csg-card-link);
		text-decoration: none;
		outline: none;
	}

	.csg-link::after {
		content: "";
		position: absolute;
		inset: 0;
		z-index: 1;
		border-radius: 28px;
	}

	/* The ring goes on the card, not the pseudo-element: the card clips its
	   overflow, which would cut an outline drawn by anything inside it. */
	.csg-card:has(.csg-link:focus-visible) {
		outline: 3px solid var(--csg-ink);
		outline-offset: 4px;
	}

	/* Fallback for a browser without :has(). */
	.csg-link:focus-visible > span:first-child {
		text-decoration: underline;
		text-decoration-thickness: 2px;
		text-underline-offset: 3px;
	}

	.csg-arrow {
		flex: none;
		width: 16px;
		height: 16px;
		color: var(--csg-card-arrow);
	}

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

	/* ---- tablet: two across ---- */
	@media (min-width: 810px) {
		.csg {
			padding-inline: 40px;
		}

		.csg-grid {
			grid-template-columns: repeat(2, minmax(0, 1fr));
		}
	}

	/* ---- desktop: three across ---- */
	@media (min-width: 1200px) {
		.csg-grid {
			grid-template-columns: repeat(3, minmax(0, 1fr));
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.csg-card {
			transition: box-shadow var(--csg-dur) var(--csg-ease);
		}

		.csg-card:hover {
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.csg-card {
			border: 1px solid CanvasText;
		}

		.csg-card:has(.csg-link:focus-visible) {
			outline-color: Highlight;
		}
	}
</style>

===== END src/components/ColorServiceGrid.astro =====

===== FILE (supporting): src/components/ColorServiceGrid.md =====
# Color service grid

A light services section: lime-dot eyebrow, a big centred heading, a short
paragraph, then a grid of tall rounded cards, each in its own colour. Every
card has an icon tile, a huge faded index number (01–06) top-right, a title,
a line of text and an "Explore service ↗" link along the bottom. The first
card carries a photo in its lower-right area behind a dark gradient.

Catalog page: `/components/color-service-grid`

## Purpose

The services overview on a homepage: six offers, each one clickable, each
one visually distinct. Use something else if services need prices or bullet
lists; these cards hold one sentence.

## Files required

**`ColorServiceGrid.astro`**, one file. No imports, no script, no npm
packages. Icons are inline SVG drawn for this library.

`public/color-service-grid/road-freight.webp` is the catalog demo's photo
for the first card; the component has no default image. See "Images and
credits".

## Basic usage

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

<ColorServiceGrid
  heading="Every mode. One accountable team."
  cards={[
    { title: "Road freight", text: "…", href: "/road", tone: "ink", icon: "truck",
      image: { src: "/img/truck.webp", alt: "Lorry on a highway at dusk" } },
    { title: "Ocean freight", text: "…", href: "/ocean", tone: "purple", icon: "ship" },
  ]}
/>
```

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `""` | Hidden when empty. |
| `heading` | `string?` | `""` | Also names the section (`aria-labelledby`). Hidden when empty. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | Card titles take the next level down. |
| `intro` | `string?` | `""` | Hidden when empty. |
| `cards` | `ServiceCard[]?` | `[]` | Cards without `title` or `href` are skipped. No cards → the component renders nothing. |
| `ctaLabel` | `string?` | `"Explore service"` | Default link label. |
| `numbered` | `boolean?` | `true` | The faded 01–06 numbers. |
| `background` | `string?` | `#eef2eb` | Section surface. |
| `fontStylesheet` | `string?` | Manrope + Inter | `""` skips the `<link>`. |
| `id` | `string?` | none | Root id; also seeds the heading id. |

```ts
interface ServiceCard {
  title: string;
  text?: string;
  href: string;
  tone?: "ink" | "purple" | "orange" | "aqua" | "lime" | "green"; // default "ink"
  icon?: "truck" | "ship" | "plane" | "warehouse" | "bike" | "shield";
  ctaLabel?: string;
  image?: { src?: string; alt?: string }; // src: "" renders the placeholder
}
```

### Tones (measured)

| Tone | Background | Text | Number | Icon tile | Link / arrow |
| :--- | :--- | :--- | :--- | :--- | :--- |
| ink | `#08110f` | white, body .72 | white .13 | lime .18, lime icon | white / lime |
| purple | `#9d8cff` | ink, body .74 | ink .10 | white .24 | black |
| orange | `#ff9a62` | ink | ink .10 | white .27 | black |
| aqua | `#73f5df` | ink | ink .10 | white .30 | black |
| lime | `#c8ff38` | ink | ink .10 | ink .10 | black |
| green | `#163c33` | white, body .72 | white .11 | orange .18, peach icon | peach `#ffb487` |

## Behaviour

- **Hover (measured):** the card goes to `translateY(-7px) scale(1.01)` and
  its shadow `0 20px 46px 0` goes from alpha 0 to **0.26** in the card's own
  hue. Transition 0.3s `cubic-bezier(.22,1,.36,1)` on transform and
  box-shadow. Under reduced motion the transform is dropped and only the
  shadow changes.
- **Whole card is the link.** The "Explore service" anchor carries a
  full-card `::after`, so clicking anywhere on the card follows it.
- **Photo card:** the photo sits 49.6% in from the left and 89px down,
  running off the right and bottom edges, with a 180px top-left radius and
  opacity 0.78, under a 90° gradient from ink .96 → .62 (55%) → .04.
- **Animation:** none, as measured.

## Accessibility

- One link per card. Its accessible name is "Explore service: Road freight"
  (the title rides in a visually hidden span), so a links list is not six
  identical "Explore service" entries.
- **Focus ring:** a 3px ink outline 4px outside the card, drawn on the card
  through `:has(.csg-link:focus-visible)`, because the card clips its own
  overflow. Browsers without `:has()` get a thick underline on the label.
- Section is named by its heading. Card titles are real headings one level
  below it. Index numbers and icons are `aria-hidden`.
- Link target is the whole 350px card, well past 44px.
- Contrast: body text on the bright tones is ink at .74 alpha, which is the
  measured value.
- Under `forced-colors: active` cards get a border and the ring uses
  `Highlight`.

## Responsive

| Width | Columns | Gutters |
| :--- | :--- | :--- |
| ≥ 1200 | 3 (max 1160px) | 40px |
| 810–1199 | 2 | 40px |
| < 810 | 1 | 20px |

Heading is 62/58.9 at −3.41px, clamped down to 44px on very narrow screens
(`clamp(44px, 16vw, 62px)`), which is 62px at 390 as measured.

## Integration notes

- Full-bleed section: paints its own surface, 112px top and 120px bottom.
- Reads `--nv-ink`, `--nv-lime`, `--nv-font`, `--nv-muted`.
- Don't put a second interactive element inside a card: the stretched link
  sits on top of it.

## Images and credits

| File | Slot | Size | Credit |
| :--- | :--- | :--- | :--- |
| `public/color-service-grid/road-freight.webp` | first card photo | 720×960 | Photo: Unsplash RxVtDbR00cg, Unsplash License |

Lazy-loaded, `object-fit: cover`. The catalog demo passes it on the first
card with alt "Articulated lorry on an open highway at dusk". Set `image.src`
to `""` and the slot renders a striped placeholder naming the subject (the
`alt`, or the card title) and "3:4 · 720 × 960 px".

## Content defaults

The component invents no business copy. Heading, eyebrow, intro and cards
default to empty; with no cards it renders nothing (only the font `<link>`),
and the intro header is omitted when eyebrow, heading and intro are all
empty. The six freight cards, the heading "Every mode. One accountable team."
and the intro paragraph live in the catalog registry's demo props. The one
remaining default, `ctaLabel` "Explore service", is generic link text.

## Approximations

- **Hover shadow alpha is 0.26** for every tone, the middle of the measured
  0.24–0.28 range.
- **Tablet is interpolated** (no capture): 2 columns.
- **The number's right inset (25px)** is derived from the measured boxes;
  Framer sets it via a layout the dump does not show directly.
- **Icons are original** simple line drawings, not the reference set.
- The phone capture didn't render card 01 (its image layer hadn't loaded);
  the phone layout of the photo card reuses the desktop placement, scaled by
  percentage from the left.
- Brief listed purple `#a88cff` and orange `#ffb86b`; the dump measures the
  service cards at `#9d8cff` and `#ff9a62`, and those are what is used.

===== END src/components/ColorServiceGrid.md =====

===== FILE (required): public/color-service-grid/road-freight.webp =====
[binary asset — download https://astro.baysixmedia.com/color-service-grid/road-freight.webp and save it to public/color-service-grid/road-freight.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
“Color service grid” from my Astro Component Library:
https://astro.baysixmedia.com/components/color-service-grid/

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/ColorServiceGrid.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/color-service-grid/road-freight.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * ColorServiceGrid
 *
 * A light services section: lime-dot eyebrow, a big centred heading, a short
 * paragraph, then a grid of tall rounded cards, each in its own colour. Every
 * card has an icon tile, a huge faded index number in the top-right corner, a
 * title, a line of text and an "Explore service" link along the bottom. Any
 * card can carry a photo, which sits in its lower-right area behind a
 * gradient so the copy stays legible.
 *
 * The whole card is one link target (a stretched link). Hover lifts the card
 * and gives it a soft shadow in its own hue.
 *
 * Three across from 1200px, two from 810px, one below. Static: no script.
 *
 * Reads --nv-* design tokens from any ancestor, with fallbacks.
 */

export type ServiceTone = "ink" | "purple" | "orange" | "aqua" | "lime" | "green";
export type ServiceIcon = "truck" | "ship" | "plane" | "warehouse" | "bike" | "shield";

export interface ServiceCard {
	title: string;
	text?: string;
	href: string;
	/** The card colour. Picks background, text, number, tile and shadow hue together. */
	tone?: ServiceTone;
	icon?: ServiceIcon;
	/** Link label. Defaults to the section's `ctaLabel`. */
	ctaLabel?: string;
	/**
	 * Photo in the card's lower-right area. `src: ""` renders the labelled
	 * placeholder instead. Omit for a plain colour card.
	 */
	image?: { src?: string; alt?: string };
}

export interface ColorServiceGridProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	intro?: string;
	cards?: ServiceCard[];
	/** Default link label on every card. */
	ctaLabel?: string;
	/** Show the 01–06 index numbers. */
	numbered?: boolean;
	background?: string;
	fontStylesheet?: string;
	id?: string;
}

interface Props extends ColorServiceGridProps {}

const {
	eyebrow = "",
	heading = "",
	headingLevel = "h2",
	intro = "",
	cards = [],
	ctaLabel = "Explore service",
	numbered = true,
	background = "#eef2eb",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap",
	id,
} = Astro.props;

const Heading = headingLevel;
const CardHeading = headingLevel === "h2" ? "h3" : "h4";
const TONES = new Set(["ink", "purple", "orange", "aqua", "lime", "green"]);
const list = (Array.isArray(cards) ? cards : []).filter((c) => c && c.title && c.href);

/* 24x24, 1.8 stroke, round caps. Drawn for this library. */
const ICONS: Record<string, string> = {
	truck:
		'<path d="M2.5 6.5h11v9h-11z"/><path d="M13.5 9.5h4l3 3v3h-7"/><circle cx="7" cy="17.5" r="1.8"/><circle cx="17" cy="17.5" r="1.8"/>',
	ship:
		'<path d="M3 14.5l1.8 4.2c2.4 1 4.8 1 7.2 0 2.4 1 4.8 1 7.2 0l1.8-4.2-9-3.5z"/><path d="M6 12.3V8h12v4.3"/><path d="M10 8V4.5h4V8"/>',
	plane:
		'<path d="M20.5 3.5c-1-1-2.6-.9-3.6.1l-3.4 3.6-8-2.4-1.9 1.9 6.5 3.8-3.3 3.5-2.6-.5-1.5 1.5 3.4 1.6 1.6 3.4 1.5-1.5-.5-2.6 3.5-3.3 3.8 6.5 1.9-1.9-2.4-8 3.6-3.4c1-1 1.1-2.6.1-3.6z"/>',
	warehouse:
		'<path d="M3 20V9l9-5 9 5v11"/><path d="M7 20v-8h10v8"/><path d="M7 15h10M7 18h10"/>',
	bike:
		'<circle cx="6" cy="16.5" r="3.5"/><circle cx="18" cy="16.5" r="3.5"/><path d="M6 16.5l4-7h5l3 7M10 9.5l2.5 7h-6.5"/><circle cx="14" cy="5" r="1.4"/>',
	shield:
		'<path d="M12 3l7.5 3v5.5c0 4.6-3.2 8.2-7.5 9.5-4.3-1.3-7.5-4.9-7.5-9.5V6z"/><path d="M8.8 12.2l2.3 2.3 4.3-4.6"/>',
};

const uid = id ?? `csg-${Math.random().toString(36).slice(2, 9)}`;
---

{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{list.length > 0 && (
<section class="csg" id={id} style={`--csg-bg: ${background};`} aria-labelledby={heading ? `${uid}-h` : undefined}>
	<div class="csg-inner">
		{
			(eyebrow || heading || intro) && (
				<header class="csg-intro">
					{eyebrow && (
						<p class="csg-eyebrow">
							<span class="csg-dot" aria-hidden="true" />
							{eyebrow}
						</p>
					)}
					{heading && <Heading class="csg-heading" id={`${uid}-h`}>{heading}</Heading>}
					{intro && <p class="csg-lede">{intro}</p>}
				</header>
			)
		}

		{
			list.length > 0 && (
				<ul class="csg-grid" role="list">
					{list.map((card, i) => {
						const tone = card.tone && TONES.has(card.tone) ? card.tone : "ink";
						const icon = card.icon && ICONS[card.icon] ? ICONS[card.icon] : null;
						const img = card.image;
						return (
							<li class:list={["csg-card", `csg-card--${tone}`, { "csg-card--photo": !!img }]}>
								{img && (
									<div class="csg-photo">
										{img.src ? (
											<img src={img.src} alt={img.alt ?? ""} width="720" height="960" loading="lazy" decoding="async" />
										) : (
											<div class="csg-ph" role="img" aria-label={`Image placeholder: ${img.alt || card.title} photo`}>
												<span class="csg-ph-text" aria-hidden="true">
													<span class="csg-ph-label">Image</span>
													<span>{img.alt || `${card.title} photo`}</span>
													<span class="csg-ph-spec">3:4 · 720 × 960 px</span>
												</span>
											</div>
										)}
									</div>
								)}
								{img && <span class="csg-scrim" aria-hidden="true" />}
								{numbered && (
									<span class="csg-num" aria-hidden="true">
										{String(i + 1).padStart(2, "0")}
									</span>
								)}
								<div class="csg-copy">
									{icon && (
										<span class="csg-tile" aria-hidden="true">
											<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false" set:html={icon} />
										</span>
									)}
									<CardHeading class="csg-title">{card.title}</CardHeading>
									{card.text && <p class="csg-text">{card.text}</p>}
								</div>
								<a class="csg-link" href={card.href}>
									<span>{card.ctaLabel ?? ctaLabel}</span>
									<span class="csg-sr">: {card.title}</span>
									<svg class="csg-arrow" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
										<path d="M5 11l6-6M6 5h5v5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
									</svg>
								</a>
							</li>
						);
					})}
				</ul>
			)
		}
	</div>
</section>
)}

<style>
	.csg {
		--csg-ink: var(--nv-ink, #08110f);
		--csg-font: var(--nv-font, Manrope, Inter, ui-sans-serif, system-ui, sans-serif);
		--csg-lime: var(--nv-lime, #c8ff38);
		--csg-ease: cubic-bezier(0.22, 1, 0.36, 1);
		--csg-dur: 0.3s;

		box-sizing: border-box;
		padding: 112px 20px 120px;
		background: var(--csg-bg);
		font-family: var(--csg-font);
		color: var(--csg-ink);
		overflow: clip;
	}

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

	.csg-inner {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 54px;
		max-width: 1160px;
		margin: 0 auto;
	}

	.csg-intro {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 18px;
		max-width: 780px;
		text-align: center;
	}

	.csg-eyebrow {
		display: flex;
		align-items: center;
		gap: 9px;
		margin: 0;
		font: 700 12px/14.4px var(--csg-font);
		letter-spacing: 1.44px;
		text-transform: uppercase;
		color: var(--csg-ink);
	}

	.csg-dot {
		flex: none;
		width: 8px;
		height: 8px;
		border-radius: 50%;
		background: var(--csg-lime);
		box-shadow: 0 0 0 6px rgb(200 255 56 / 0.18);
	}

	.csg-heading {
		max-width: 760px;
		margin: 0;
		font-weight: 700;
		font-size: clamp(44px, 16vw, 62px);
		line-height: 0.95;
		letter-spacing: -0.055em;
		color: var(--csg-ink);
		text-wrap: balance;
	}

	.csg-lede {
		max-width: 610px;
		margin: 0;
		font: 400 17px/27.2px var(--csg-font);
		color: var(--nv-muted, #5b6c65);
		text-wrap: pretty;
	}

	/* The phone reference holds the lede to ~310px, narrower than the cards. */
	@media (max-width: 809px) {
		.csg-lede {
			padding-inline: 20px;
		}
	}

	.csg-grid {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 18px;
		width: 100%;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	/* ---- the card ---- */

	.csg-card {
		/* Tone defaults: ink card. Each tone below overrides these. */
		--csg-card-bg: #08110f;
		--csg-card-fg: #fff;
		--csg-card-muted: rgb(255 255 255 / 0.72);
		--csg-card-num: rgb(255 255 255 / 0.13);
		--csg-card-tile: rgb(200 255 56 / 0.18);
		--csg-card-icon: #c8ff38;
		--csg-card-link: #fff;
		--csg-card-arrow: #c8ff38;
		--csg-card-hue: 8 17 15;

		position: relative;
		display: flex;
		flex-direction: column;
		justify-content: space-between;
		align-items: flex-start;
		gap: 32px;
		min-height: 350px;
		min-width: 0;
		padding: 30px;
		border-radius: 28px;
		background: var(--csg-card-bg);
		color: var(--csg-card-fg);
		overflow: clip;
		isolation: isolate;
		box-shadow: 0 20px 46px 0 rgb(var(--csg-card-hue) / 0);
		transition:
			transform var(--csg-dur) var(--csg-ease),
			box-shadow var(--csg-dur) var(--csg-ease);
	}

	.csg-card--purple {
		--csg-card-bg: #9d8cff;
		--csg-card-hue: 157 140 255;
		--csg-card-tile: rgb(255 255 255 / 0.24);
	}

	.csg-card--orange {
		--csg-card-bg: #ff9a62;
		--csg-card-hue: 255 154 98;
		--csg-card-tile: rgb(255 255 255 / 0.27);
	}

	.csg-card--aqua {
		--csg-card-bg: #73f5df;
		--csg-card-hue: 115 245 223;
		--csg-card-tile: rgb(255 255 255 / 0.3);
	}

	.csg-card--lime {
		--csg-card-bg: #c8ff38;
		--csg-card-hue: 200 255 56;
		--csg-card-tile: rgb(8 17 15 / 0.1);
	}

	.csg-card--purple,
	.csg-card--orange,
	.csg-card--aqua,
	.csg-card--lime {
		--csg-card-fg: #08110f;
		--csg-card-muted: rgb(8 17 15 / 0.74);
		--csg-card-num: rgb(8 17 15 / 0.1);
		--csg-card-icon: #08110f;
		--csg-card-link: #000;
		--csg-card-arrow: #000;
	}

	.csg-card--green {
		--csg-card-bg: #163c33;
		--csg-card-hue: 22 60 51;
		--csg-card-num: rgb(255 255 255 / 0.11);
		--csg-card-tile: rgb(255 154 98 / 0.18);
		--csg-card-icon: #ffb487;
		--csg-card-link: #ffb487;
		--csg-card-arrow: #ffb487;
	}

	.csg-card:hover {
		transform: translateY(-7px) scale(1.01);
		box-shadow: 0 20px 46px 0 rgb(var(--csg-card-hue) / 0.26);
	}

	.csg-photo {
		position: absolute;
		/* Measured on a 375x350 card: 186px in, 89px down, 217x287,
		   running off the right and bottom edges. */
		inset: 89px -28px -26px 49.6%;
		z-index: -2;
		border-top-left-radius: 180px;
		overflow: hidden;
		opacity: 0.78;
	}

	.csg-photo img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.csg-scrim {
		position: absolute;
		inset: 0;
		z-index: -1;
		background: linear-gradient(
			90deg,
			rgb(8 17 15 / 0.96) 0%,
			rgb(8 17 15 / 0.62) 55%,
			rgb(8 17 15 / 0.04) 100%
		);
		pointer-events: none;
	}

	.csg-ph {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		padding: 40px 36px 36px 44px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #1d2a27 0 12px, #243330 12px 24px);
		color: rgb(255 255 255 / 0.7);
	}

	.csg-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 6px;
		font: 500 12px/1.35 var(--csg-font);
	}

	.csg-ph-label {
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.csg-ph-spec {
		font-weight: 400;
		opacity: 0.8;
	}

	.csg-num {
		position: absolute;
		top: 16px;
		right: 25px;
		z-index: -1;
		font: 700 72px/72px var(--csg-font);
		letter-spacing: -5.04px;
		color: var(--csg-card-num);
		pointer-events: none;
		user-select: none;
	}

	.csg-copy {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
		max-width: 315px;
	}

	.csg-tile {
		display: grid;
		place-items: center;
		width: 56px;
		height: 56px;
		border-radius: 18px;
		background: var(--csg-card-tile);
		color: var(--csg-card-icon);
	}

	.csg-tile svg {
		width: 25px;
		height: 25px;
	}

	.csg-title {
		margin: 0;
		font: 700 31px/32.55px var(--csg-font);
		letter-spacing: -1.395px;
		color: var(--csg-card-fg);
	}

	.csg-text {
		margin: 0;
		font: 400 14px/22.12px var(--csg-font);
		color: var(--csg-card-muted);
	}

	/* Stretched link: the ::after covers the whole card, so the card is one
	   target while the accessible name stays on the link itself. */
	.csg-link {
		display: inline-flex;
		align-items: center;
		gap: 8px;
		min-height: 24px;
		font: 700 13px/15.6px var(--csg-font);
		color: var(--csg-card-link);
		text-decoration: none;
		outline: none;
	}

	.csg-link::after {
		content: "";
		position: absolute;
		inset: 0;
		z-index: 1;
		border-radius: 28px;
	}

	/* The ring goes on the card, not the pseudo-element: the card clips its
	   overflow, which would cut an outline drawn by anything inside it. */
	.csg-card:has(.csg-link:focus-visible) {
		outline: 3px solid var(--csg-ink);
		outline-offset: 4px;
	}

	/* Fallback for a browser without :has(). */
	.csg-link:focus-visible > span:first-child {
		text-decoration: underline;
		text-decoration-thickness: 2px;
		text-underline-offset: 3px;
	}

	.csg-arrow {
		flex: none;
		width: 16px;
		height: 16px;
		color: var(--csg-card-arrow);
	}

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

	/* ---- tablet: two across ---- */
	@media (min-width: 810px) {
		.csg {
			padding-inline: 40px;
		}

		.csg-grid {
			grid-template-columns: repeat(2, minmax(0, 1fr));
		}
	}

	/* ---- desktop: three across ---- */
	@media (min-width: 1200px) {
		.csg-grid {
			grid-template-columns: repeat(3, minmax(0, 1fr));
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.csg-card {
			transition: box-shadow var(--csg-dur) var(--csg-ease);
		}

		.csg-card:hover {
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.csg-card {
			border: 1px solid CanvasText;
		}

		.csg-card:has(.csg-link:focus-visible) {
			outline-color: Highlight;
		}
	}
</style>