Component library
Back to catalog

Bento feature grid

A centred statement over an asymmetric grid of dark feature cards whose media zooms and plays under the pointer.

sectiongridmedia

Open preview in a new tab

Use this component

Three ways to get Bento feature 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 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Bento feature grid from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/bento-feature-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 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Bento feature grid"
Library page: https://astro.baysixmedia.com/components/bento-feature-grid/
Files: 1 required, 1 supporting

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

===== FILE (required): src/components/BentoFeatureGrid.astro =====
---
/**
 * BentoFeatureGrid — a centred statement over an asymmetric grid of dark
 * feature cards, each pairing a short claim with a media panel that comes to
 * life under the pointer.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * and no bundled assets: drop it into any Astro project and render it.
 *
 * Cards claim their footprint by name rather than by coordinates — a card is
 * "tall", "wide" or "full" — so the mosaic rebuilds itself for any number of
 * them instead of breaking when one is added.
 *
 * Measured behaviour:
 *   grid          4 tracks at >=1024, 2 at >=768, 1 below
 *   card          dark panel, 12px radius, hairline border, hover lift
 *   media         scales to 1.08 over 500ms ease-out under the pointer
 *   video         lazy; plays on hover and pauses on leave
 *   heading       reveals word by word, each rising 18px
 *   cards         reveal at scale(0.95) translateY(40px), staggered after it
 *
 * Several instances can share a page: every observer and listener is built
 * per root, so two grids never reveal or play each other's cards.
 */

export interface BentoMedia {
	/** Image or video URL. Anything the host can serve. */
	src: string;
	/** Treat `src` as a muted looping video that plays on hover. */
	video?: boolean;
	/** Still shown before the video plays, and under reduced motion. */
	poster?: string;
	/** Alternative text. Required for an image that carries meaning. */
	alt?: string;
}

export interface BentoCard {
	/** Used for the card's element id. Falls back to no id. */
	id?: string;
	/** The claim, set in the card's title size. */
	title: string;
	/** Supporting sentence under the title. */
	body?: string;
	/** `d` attribute for a 24x24 stroked icon beside the title. */
	icon?: string;
	/** Fills the card's media panel. Omit for the drawn placeholder. */
	media?: BentoMedia;
	/** Caption drawn on the placeholder when there is no media. */
	placeholderLabel?: string;
	/**
	 * Where the media sits relative to the text.
	 * "media-first" is the default; "side-by-side" only splits at >=1024.
	 */
	layout?: "text-first" | "media-first" | "side-by-side";
	/**
	 * Footprint at >=1024. "wide" is half the row, "tall" is half the row and
	 * two rows deep, "full" is the whole row.
	 */
	span?: "wide" | "tall" | "full";
}

interface Props {
	/** Small tracked label above the heading. Empty string hides it. */
	eyebrow?: string;
	/** Section heading. Revealed one word at a time. Empty string hides it. */
	heading?: string;
	/** Paragraph under the heading. Empty string hides it. */
	intro?: string;
	/** The cards. Four reproduce the tested mosaic; any count works. */
	cards?: BentoCard[];
	/** Heading level, for pages where h2 is wrong. */
	headingLevel?: "h1" | "h2" | "h3";
	/** Section background. */
	surface?: string;
	/** Card background. */
	cardSurface?: string;
	/** Section text colour. Card text is derived from white. */
	ink?: string;
	/** Tint of the drawn media placeholder. */
	accent?: string;
	/** Reveal the heading and cards as they enter the viewport. */
	reveal?: boolean;
	class?: string;
	id?: string;
}

const {
	eyebrow = "",
	heading = "",
	intro = "",
	cards: cardsProp = [],
	headingLevel = "h2",
	surface = "#f7f7f8",
	cardSurface = "#0a0a0b",
	ink = "#2a2724",
	accent = "#5b6ee0",
	reveal = true,
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/* A card with no title has nothing to say; drop it rather than draw an empty panel.
   No cards, no section: a heading alone is not a grid. */
const cards = cardsProp.filter((card) => card && card.title);
const hasContent = cards.length > 0;

/* Split on whitespace so each word can carry its own delay. */
const words = heading ? heading.split(/\s+/).filter(Boolean) : [];

const NEUTRAL_ICON = "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z";
---

{hasContent && (
<section
	class:list={["bento", className]}
	id={id}
	data-bento
	data-reveal={reveal ? "" : undefined}
	style={`--bf-surface:${surface};--bf-card:${cardSurface};--bf-ink:${ink};--bf-accent:${accent};`}
>
	<div class="shell">
		{(eyebrow || words.length > 0 || intro) && (
		<header class="head">
			{
				eyebrow && (
					<p class="eyebrow" data-lift style="--d:0ms">
						{eyebrow}
					</p>
				)
			}
			{
				words.length > 0 && (
					<Heading class="heading" data-words>
						{
							/*
							 * The trailing space lives inside the span, held by
							 * `white-space: pre`. Putting it between the spans instead
							 * would let the HTML collapse it, which leaves the heading
							 * reading as one run-together word to a screen reader even
							 * though a margin had made it look spaced.
							 */
							words.map((word, i) => (
								<span class="word" style={`--i:${i}`}>
									{i < words.length - 1 ? `${word} ` : word}
								</span>
							))
						}
					</Heading>
				)
			}
			{
				intro && (
					<p class="intro" data-lift style={`--d:${140 + words.length * 60}ms`}>
						{intro}
					</p>
				)
			}
		</header>
		)}

		<div class="grid">
			{
				cards.map((card, i) => {
					const layout = card.layout ?? "media-first";
					const span = card.span ?? "wide";
					return (
						<article
							class:list={["card", `span-${span}`, `layout-${layout}`]}
							id={card.id}
							data-card
							style={`--d:${i * 90}ms`}
						>
							<div class="card-inner">
								<div class="card-text">
									<div class="card-head">
										<svg
											class="card-icon"
											viewBox="0 0 24 24"
											width="16"
											height="16"
											fill="none"
											stroke="currentColor"
											stroke-width="1.6"
											stroke-linecap="round"
											stroke-linejoin="round"
											aria-hidden="true"
										>
											<path d={card.icon ?? NEUTRAL_ICON} />
										</svg>
										<h3 class="card-title">{card.title}</h3>
									</div>
									{card.body && <p class="card-body">{card.body}</p>}
								</div>

								<div class="card-media">
									<div class="media-scale">
										{card.media ? (
											card.media.video ? (
												<video
													class="media-fill"
													muted
													loop
													playsinline
													preload="none"
													poster={card.media.poster}
													aria-label={card.media.alt}
													data-video
												>
													<source src={card.media.src} />
												</video>
											) : (
												<img
													class="media-fill"
													src={card.media.src}
													alt={card.media.alt ?? ""}
													loading="lazy"
													decoding="async"
												/>
											)
										) : (
											<span class="placeholder" aria-hidden="true">
												{card.placeholderLabel ?? ""}
											</span>
										)}
									</div>
								</div>
							</div>
						</article>
					);
				})
			}
		</div>
	</div>
</section>
)}

<style>
	.bento {
		--bf-muted: color-mix(in srgb, var(--bf-ink) 72%, transparent);
		--bf-card-ink: #ffffff;
		--bf-card-title: color-mix(in srgb, #fff 82%, transparent);
		--bf-card-body: color-mix(in srgb, #fff 62%, transparent);
		--bf-card-line: color-mix(in srgb, #fff 7%, transparent);

		--bf-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		/* 30px at a 390px viewport, 48px from about 1024px up. */
		--bf-display: clamp(1.875rem, 1.1rem + 2.4vw, 3rem);
		--bf-body: 1rem;
		--bf-card-size: 1rem;

		--bf-gutter: 24px;
		--bf-maxw: 1024px;
		--bf-radius: 12px;
		--bf-ease: cubic-bezier(0.16, 1, 0.3, 1);

		box-sizing: border-box;
		display: block;
		position: relative;
		overflow: hidden;
		padding-block: clamp(72px, 9vw, 128px);
		background: var(--bf-surface);
		color: var(--bf-ink);
		font-family: var(--bf-font);
	}

	.bento :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.shell {
		max-width: var(--bf-maxw);
		margin-inline: auto;
		padding-inline: var(--bf-gutter);
	}

	/* ---------- header ---------- */

	.head {
		text-align: center;
	}

	.eyebrow {
		margin: 0;
		font-size: 0.75rem;
		font-weight: 500;
		letter-spacing: 0.2em;
		text-transform: uppercase;
		color: color-mix(in srgb, var(--bf-ink) 75%, transparent);
	}

	.heading {
		margin: 20px 0 0;
		font-size: var(--bf-display);
		line-height: 1.05;
		font-weight: 700;
		letter-spacing: -0.025em;
		text-wrap: balance;
	}

	/*
	 * Each word is its own box so it can rise independently. Inline-block
	 * boxes sit flush against each other — the whitespace between them in the
	 * source collapses away — so the gap between words is put back as a
	 * margin, roughly the width of a space.
	 */
	.word {
		display: inline-block;
		white-space: pre;
	}

	.intro {
		max-width: 42rem;
		margin: 20px auto 0;
		font-size: var(--bf-body);
		line-height: 1.6;
		color: var(--bf-muted);
		text-wrap: pretty;
	}

	/* ---------- grid ---------- */

	.grid {
		display: grid;
		grid-template-columns: 1fr;
		gap: 16px;
		margin-top: 64px;
	}

	.card {
		min-width: 0;
	}

	.card-inner {
		display: flex;
		flex-direction: column;
		height: 100%;
		overflow: hidden;
		border: 1px solid var(--bf-card-line);
		border-radius: var(--bf-radius);
		background: var(--bf-card);
		box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
		transition:
			box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1),
			transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	/*
	 * The text comes first in the source, so a plain column already reads as
	 * "text-first" and it is the other two arrangements that reverse. Keeping
	 * the text ahead of the media in the DOM means a screen reader meets the
	 * claim before the decoration whichever way the card is drawn.
	 */
	.layout-media-first .card-inner {
		flex-direction: column-reverse;
	}

	.card-text {
		padding: 24px;
	}

	.card-head {
		display: flex;
		align-items: center;
		gap: 8px;
		margin-bottom: 6px;
	}

	.card-icon {
		flex-shrink: 0;
		color: var(--bf-card-title);
	}

	.card-title {
		margin: 0;
		font-size: var(--bf-card-size);
		line-height: 1.4;
		font-weight: 600;
		color: var(--bf-card-title);
		transition: color 300ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	.card-body {
		margin: 0;
		padding-left: 24px;
		font-size: 0.875rem;
		line-height: 1.4;
		color: var(--bf-card-body);
		text-wrap: pretty;
	}

	/* ---------- media ---------- */

	.card-media {
		position: relative;
		flex: 1;
		min-height: 10rem;
		overflow: hidden;
		background: #000;
	}

	/* The element that scales, so the panel itself can stay put and clip it. */
	.media-scale {
		position: absolute;
		inset: 0;
		display: grid;
		place-items: center;
		transform-origin: center;
		transition: transform 500ms cubic-bezier(0, 0, 0.2, 1);
		will-change: transform;
	}

	.media-fill {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* Drawn stand-in for a card with no media: nothing to download. */
	.media-scale:has(.placeholder) {
		background:
			radial-gradient(
				ellipse 60% 55% at 50% 42%,
				color-mix(in srgb, var(--bf-accent) 30%, transparent) 0%,
				transparent 72%
			),
			radial-gradient(
				circle at 1px 1px,
				color-mix(in srgb, #fff 8%, transparent) 1px,
				transparent 0
			);
		background-size: cover, 22px 22px;
	}

	.placeholder {
		position: relative;
		font-size: 0.6875rem;
		font-weight: 600;
		letter-spacing: 0.3em;
		color: color-mix(in srgb, #fff 42%, transparent);
	}

	/* ---------- reveal ----------
	 * Hidden in CSS rather than armed by script, so the first paint is already
	 * correct and nothing flashes visible then snaps away.
	 */

	.bento[data-reveal] .word {
		opacity: 0;
		transform: translateY(18px);
		transition:
			opacity 600ms var(--bf-ease) calc(var(--i) * 60ms + 80ms),
			transform 600ms var(--bf-ease) calc(var(--i) * 60ms + 80ms);
	}

	.bento[data-reveal] [data-lift] {
		opacity: 0;
		transform: translateY(18px);
		transition:
			opacity 600ms var(--bf-ease) var(--d, 0ms),
			transform 600ms var(--bf-ease) var(--d, 0ms);
	}

	.bento[data-reveal] [data-card] {
		opacity: 0;
		transform: scale(0.95) translateY(40px);
		transition:
			opacity 700ms var(--bf-ease) var(--d, 0ms),
			transform 700ms var(--bf-ease) var(--d, 0ms);
	}

	.bento[data-reveal] [data-in] .word,
	.bento[data-reveal] [data-lift][data-in],
	.bento[data-reveal] [data-card][data-in] {
		opacity: 1;
		transform: none;
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 768px) {
		.bento {
			--bf-gutter: 24px;
		}

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

		.span-tall,
		.span-full {
			grid-column: span 2;
		}
	}

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

		.span-wide {
			grid-column: span 2;
		}

		.span-tall {
			grid-column: span 2;
			grid-row: span 2;
		}

		.span-full {
			grid-column: span 4;
		}

		/* Only the full-width card has the room to split left and right, with
		   the media leading and the text set beside it. */
		.layout-side-by-side .card-inner {
			flex-direction: row-reverse;
			align-items: stretch;
		}

		.layout-side-by-side .card-text {
			flex: 1;
			align-self: center;
			padding: 32px;
		}

		.layout-side-by-side .card-media {
			flex: 1;
			min-height: 11rem;
		}

		.span-tall .card-text,
		.span-full .card-text {
			padding: 32px;
		}
	}

	/* Hover is a pointer affordance. On a touch screen :hover sticks after the
	   finger lifts, which would leave a card zoomed for no reason. */
	@media (hover: hover) and (pointer: fine) {
		.card:hover .media-scale {
			transform: scale(1.08);
		}

		.card:hover .card-inner {
			box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.14), 0 8px 10px -6px rgb(0 0 0 / 0.14);
		}

		.card:hover .card-title {
			color: var(--bf-card-ink);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.bento[data-reveal] :is(.word, [data-lift], [data-card]) {
			opacity: 1;
			transform: none;
			transition: none;
		}

		.media-scale,
		.card-inner,
		.card-title {
			transition: none;
		}

		/* A zoom on hover is motion the reader asked not to see. */
		.card:hover .media-scale {
			transform: none;
		}
	}
</style>

<noscript>
	<style>
		.bento[data-reveal] :is(.word, [data-lift], [data-card]) {
			opacity: 1;
			transform: none;
		}
	</style>
</noscript>

<script>
	/*
	 * Two behaviours, both built per instance so two grids on a page never
	 * reveal or play each other's cards: the entrance, and hover-to-play.
	 */

	const prefersReduced = () =>
		window.matchMedia("(prefers-reduced-motion: reduce)").matches;

	const setupReveal = (root: HTMLElement) => {
		if (!root.hasAttribute("data-reveal")) return;

		const groups = [
			...root.querySelectorAll<HTMLElement>("[data-words], [data-lift], [data-card]"),
		];
		if (groups.length === 0) return;

		if (!("IntersectionObserver" in window)) {
			groups.forEach((group) => group.setAttribute("data-in", ""));
			return;
		}

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					entry.target.setAttribute("data-in", "");
					observer.unobserve(entry.target);
				}
			},
			/*
			 * The large top margin keeps a jump safe. An observer reports only a
			 * *change* in intersection, so an element that goes from below the
			 * viewport to above it in one jump would read 0 both times, never
			 * fire, and stay invisible for good.
			 */
			{ rootMargin: "10000px 0px -10% 0px", threshold: 0 },
		);

		groups.forEach((group) => observer.observe(group));
	};

	const setupVideos = (root: HTMLElement) => {
		const cards = [...root.querySelectorAll<HTMLElement>("[data-card]")];

		for (const card of cards) {
			const video = card.querySelector<HTMLVideoElement>("[data-video]");
			if (!video) continue;

			/* Playing on hover is already opt-in, but a reader who asked for less
			   motion has opted out of it; the poster stands in. */
			const play = () => {
				if (prefersReduced()) return;
				/* A rejected play() is normal — a background tab, or a policy that
				   wants a gesture first. The poster simply stays. */
				video.play().catch(() => {});
			};
			const stop = () => video.pause();

			card.addEventListener("pointerenter", play);
			card.addEventListener("pointerleave", stop);
			/* Keyboard readers never fire pointer events, so mirror it on focus. */
			card.addEventListener("focusin", play);
			card.addEventListener("focusout", stop);
		}
	};

	for (const root of document.querySelectorAll<HTMLElement>("[data-bento]")) {
		setupReveal(root);
		setupVideos(root);
	}
</script>

===== END src/components/BentoFeatureGrid.astro =====

===== FILE (supporting): src/components/BentoFeatureGrid.md =====
# Bento feature grid

A centred statement over an asymmetric grid of dark feature cards. Cards claim
their footprint by name — `tall`, `wide`, `full` — so the mosaic rebuilds
itself for any number of them. Each card's media panel zooms under the pointer,
and a video card plays only while you are on it.

Catalog page: `/components/bento-feature-grid`

## Copying it into another Astro project

Copy **`BentoFeatureGrid.astro`**. That is the whole component.

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--bf-*` property on the root |
| Webfonts | none — system stack |
| Bundled images, video, icons | **none** — see below |
| Network requests on load | none, unless you pass media |
| Host requirement | Astro compiles `.astro` files |

Two things that would normally be assets are drawn instead: the **media
placeholder** (a gradient and dot field, shown whenever a card has no `media`)
and the **card icons** (inline `<svg>` paths you supply). So the component
ships with zero files beside it. Pass `media` on a card and *that* file becomes
your dependency — nothing else does.

### Exact steps

1. Copy `BentoFeatureGrid.astro` into your components folder, e.g.
   `src/components/BentoFeatureGrid.astro`.
2. Import and render it. Every prop is optional, but nothing is invented: with
   no `cards` the component renders nothing at all. The catalog page's
   four-card demo is passed as props, not built in.

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

<BentoFeatureGrid
  eyebrow="The difference"
  heading="Why it matters after launch"
  intro="One sentence of framing."
  cards={[
    { title: "We read the system first", body: "…",
      span: "tall", layout: "text-first", placeholderLabel: "AUDIT" },
    { title: "One team, one thread", body: "…", span: "wide" },
    { title: "The smallest thing that works", body: "…", span: "wide" },
    { title: "It still works after we go", body: "…",
      span: "full", layout: "side-by-side",
      media: { src: "/clip.mp4", video: true, poster: "/clip.jpg" } },
  ]}
/>
```

3. There is no step three. The section paints its own background edge to edge,
   so give it the full page width rather than nesting it in a padded container.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `cards` | `BentoCard[]` | `[]` | The cards. Any count works; one renders one. A card without a `title` is dropped. None renders nothing. |
| `eyebrow` | `string` | `""` | Tracked label above the heading. Hidden when empty. |
| `heading` | `string` | `""` | Revealed one word at a time. Hidden when empty. |
| `intro` | `string` | `""` | Paragraph under the heading. Hidden when empty. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is wrong. |
| `surface` | `string` | `"#f7f7f8"` | Section background. |
| `cardSurface` | `string` | `"#0a0a0b"` | Card background. |
| `ink` | `string` | `"#2a2724"` | Section text colour. |
| `accent` | `string` | `"#5b6ee0"` | Tint of the drawn media placeholder. |
| `reveal` | `boolean` | `true` | Reveal the heading and cards on arrival. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | — | Root element id. |

### `BentoCard` and `BentoMedia`

```ts
interface BentoCard {
  id?: string;
  title: string;
  body?: string;
  icon?: string;              // `d` for a 24x24 stroked icon
  media?: BentoMedia;         // omit for the drawn placeholder
  placeholderLabel?: string;  // caption on the placeholder
  layout?: "text-first" | "media-first" | "side-by-side";   // default media-first
  span?: "wide" | "tall" | "full";                          // default wide
}

interface BentoMedia {
  src: string;
  video?: boolean;   // muted, looping, plays on hover
  poster?: string;   // still shown before play and under reduced motion
  alt?: string;
}
```

**`span` is a footprint, not a coordinate.** `wide` takes half the row, `tall`
takes half the row and two rows of height, `full` takes the whole row. Because
nothing is pinned to an explicit column or row, adding a fifth card reflows the
mosaic instead of colliding with a hard-coded position. The demo's
`tall, wide, wide, full` is what produces the reference arrangement.

**The text always precedes the media in the DOM**, whatever `layout` says — the
arrangement is done with `flex-direction`, so a screen reader meets the claim
before the decoration in every variant.

## Layout

| | ≥1024 | 768–1023 | <768 |
| :--- | :--- | :--- | :--- |
| Grid | 4 tracks | 2 tracks | 1 track |
| `wide` | 2 tracks | 1 track | full row |
| `tall` | 2 tracks × 2 rows | 2 tracks | full row |
| `full` | 4 tracks | 2 tracks | full row |
| `side-by-side` | media and text side by side | stacks | stacks |
| Gap | 20px | 16px | 16px |
| Heading | ~48px | fluid | 30px |
| Container | 1024px max, 24px gutter | ← | ← |

`side-by-side` only splits at `1024px` and above; below that it stacks like any
other card, because a half-width media panel beside a paragraph is unreadable
at phone widths.

## Behaviour

- **Heading reveal.** The heading is split into words, each in its own
  `inline-block` box, rising `18px` and fading in on a `60ms` cascade. The
  trailing space is kept *inside* each span and held open with
  `white-space: pre` — put between the spans it would collapse, and the heading
  would read as one run-together word to a screen reader even though a margin
  had made it look spaced.
- **Card reveal.** Cards arrive at `scale(0.95) translateY(40px)` and settle,
  staggered `90ms` apart, after the heading.
- **Hover.** The media panel scales to `1.08` over `500ms` ease-out while the
  card itself lifts its shadow and the title brightens. Gated behind
  `(hover: hover) and (pointer: fine)`, so a tap does not strand a card zoomed.
- **Video.** A `video` card is `preload="none"` and plays only while the pointer
  is on the card, pausing on leave. It also plays on `focusin`, so if you put a
  link inside a card a keyboard reader gets the same behaviour. A rejected
  `play()` is swallowed — the poster simply stays.
- **Several per page.** Every observer and listener is built per root, so two
  grids never reveal or play each other's cards.

### Accessibility notes

- **The cards are not interactive** — no links, no buttons, nothing focusable —
  which matches the reference. The hover effect is decoration over static text,
  so there is no keyboard behaviour to miss. If you make a card actionable, put
  a real `<a>` inside it and the video will follow focus.
- Icons and the placeholder caption are `aria-hidden`.
- **Reduced motion:** `prefers-reduced-motion: reduce` shows the heading and
  cards immediately, removes the transitions, **and cancels the hover zoom** —
  the scale is reset rather than merely un-eased, so the pointer triggers no
  movement at all. Videos do not autoplay under it either.

### Without JavaScript

The pre-reveal state is CSS keyed on `data-reveal`, so the first paint is
correct and nothing flashes. A `<noscript>` block then shows the heading and
all cards. Hover and the zoom are pure CSS and keep working; only
play-on-hover needs the script.

## Notes on the reference

The idea — a centred statement over an asymmetric mosaic of dark cards with
media that wakes under the pointer — was studied from a live marketing page.
**None of that site's content came with it.** The eyebrow, heading, card
claims and body copy are written for this catalog; no text, image, video,
testimonial, logo or brand colour was copied. The reference fills its cards
with four videos and four poster images; this component draws its own
placeholder and loads nothing.

===== END src/components/BentoFeatureGrid.md =====

--- VERIFICATION ---
This component is not enrolled in the library's verification system, so there is no
recorded evidence about it either way. Treat it as unverified and test it yourself.

--- TEST IN THIS PROJECT AFTER INSTALLING ---
- 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
“Bento feature grid” from my Astro Component Library:
https://astro.baysixmedia.com/components/bento-feature-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.

src/components/library/BentoFeatureGrid.astro

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

---
/**
 * BentoFeatureGrid — a centred statement over an asymmetric grid of dark
 * feature cards, each pairing a short claim with a media panel that comes to
 * life under the pointer.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * and no bundled assets: drop it into any Astro project and render it.
 *
 * Cards claim their footprint by name rather than by coordinates — a card is
 * "tall", "wide" or "full" — so the mosaic rebuilds itself for any number of
 * them instead of breaking when one is added.
 *
 * Measured behaviour:
 *   grid          4 tracks at >=1024, 2 at >=768, 1 below
 *   card          dark panel, 12px radius, hairline border, hover lift
 *   media         scales to 1.08 over 500ms ease-out under the pointer
 *   video         lazy; plays on hover and pauses on leave
 *   heading       reveals word by word, each rising 18px
 *   cards         reveal at scale(0.95) translateY(40px), staggered after it
 *
 * Several instances can share a page: every observer and listener is built
 * per root, so two grids never reveal or play each other's cards.
 */

export interface BentoMedia {
	/** Image or video URL. Anything the host can serve. */
	src: string;
	/** Treat `src` as a muted looping video that plays on hover. */
	video?: boolean;
	/** Still shown before the video plays, and under reduced motion. */
	poster?: string;
	/** Alternative text. Required for an image that carries meaning. */
	alt?: string;
}

export interface BentoCard {
	/** Used for the card's element id. Falls back to no id. */
	id?: string;
	/** The claim, set in the card's title size. */
	title: string;
	/** Supporting sentence under the title. */
	body?: string;
	/** `d` attribute for a 24x24 stroked icon beside the title. */
	icon?: string;
	/** Fills the card's media panel. Omit for the drawn placeholder. */
	media?: BentoMedia;
	/** Caption drawn on the placeholder when there is no media. */
	placeholderLabel?: string;
	/**
	 * Where the media sits relative to the text.
	 * "media-first" is the default; "side-by-side" only splits at >=1024.
	 */
	layout?: "text-first" | "media-first" | "side-by-side";
	/**
	 * Footprint at >=1024. "wide" is half the row, "tall" is half the row and
	 * two rows deep, "full" is the whole row.
	 */
	span?: "wide" | "tall" | "full";
}

interface Props {
	/** Small tracked label above the heading. Empty string hides it. */
	eyebrow?: string;
	/** Section heading. Revealed one word at a time. Empty string hides it. */
	heading?: string;
	/** Paragraph under the heading. Empty string hides it. */
	intro?: string;
	/** The cards. Four reproduce the tested mosaic; any count works. */
	cards?: BentoCard[];
	/** Heading level, for pages where h2 is wrong. */
	headingLevel?: "h1" | "h2" | "h3";
	/** Section background. */
	surface?: string;
	/** Card background. */
	cardSurface?: string;
	/** Section text colour. Card text is derived from white. */
	ink?: string;
	/** Tint of the drawn media placeholder. */
	accent?: string;
	/** Reveal the heading and cards as they enter the viewport. */
	reveal?: boolean;
	class?: string;
	id?: string;
}

const {
	eyebrow = "",
	heading = "",
	intro = "",
	cards: cardsProp = [],
	headingLevel = "h2",
	surface = "#f7f7f8",
	cardSurface = "#0a0a0b",
	ink = "#2a2724",
	accent = "#5b6ee0",
	reveal = true,
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/* A card with no title has nothing to say; drop it rather than draw an empty panel.
   No cards, no section: a heading alone is not a grid. */
const cards = cardsProp.filter((card) => card && card.title);
const hasContent = cards.length > 0;

/* Split on whitespace so each word can carry its own delay. */
const words = heading ? heading.split(/\s+/).filter(Boolean) : [];

const NEUTRAL_ICON = "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z";
---

{hasContent && (
<section
	class:list={["bento", className]}
	id={id}
	data-bento
	data-reveal={reveal ? "" : undefined}
	style={`--bf-surface:${surface};--bf-card:${cardSurface};--bf-ink:${ink};--bf-accent:${accent};`}
>
	<div class="shell">
		{(eyebrow || words.length > 0 || intro) && (
		<header class="head">
			{
				eyebrow && (
					<p class="eyebrow" data-lift style="--d:0ms">
						{eyebrow}
					</p>
				)
			}
			{
				words.length > 0 && (
					<Heading class="heading" data-words>
						{
							/*
							 * The trailing space lives inside the span, held by
							 * `white-space: pre`. Putting it between the spans instead
							 * would let the HTML collapse it, which leaves the heading
							 * reading as one run-together word to a screen reader even
							 * though a margin had made it look spaced.
							 */
							words.map((word, i) => (
								<span class="word" style={`--i:${i}`}>
									{i < words.length - 1 ? `${word} ` : word}
								</span>
							))
						}
					</Heading>
				)
			}
			{
				intro && (
					<p class="intro" data-lift style={`--d:${140 + words.length * 60}ms`}>
						{intro}
					</p>
				)
			}
		</header>
		)}

		<div class="grid">
			{
				cards.map((card, i) => {
					const layout = card.layout ?? "media-first";
					const span = card.span ?? "wide";
					return (
						<article
							class:list={["card", `span-${span}`, `layout-${layout}`]}
							id={card.id}
							data-card
							style={`--d:${i * 90}ms`}
						>
							<div class="card-inner">
								<div class="card-text">
									<div class="card-head">
										<svg
											class="card-icon"
											viewBox="0 0 24 24"
											width="16"
											height="16"
											fill="none"
											stroke="currentColor"
											stroke-width="1.6"
											stroke-linecap="round"
											stroke-linejoin="round"
											aria-hidden="true"
										>
											<path d={card.icon ?? NEUTRAL_ICON} />
										</svg>
										<h3 class="card-title">{card.title}</h3>
									</div>
									{card.body && <p class="card-body">{card.body}</p>}
								</div>

								<div class="card-media">
									<div class="media-scale">
										{card.media ? (
											card.media.video ? (
												<video
													class="media-fill"
													muted
													loop
													playsinline
													preload="none"
													poster={card.media.poster}
													aria-label={card.media.alt}
													data-video
												>
													<source src={card.media.src} />
												</video>
											) : (
												<img
													class="media-fill"
													src={card.media.src}
													alt={card.media.alt ?? ""}
													loading="lazy"
													decoding="async"
												/>
											)
										) : (
											<span class="placeholder" aria-hidden="true">
												{card.placeholderLabel ?? ""}
											</span>
										)}
									</div>
								</div>
							</div>
						</article>
					);
				})
			}
		</div>
	</div>
</section>
)}

<style>
	.bento {
		--bf-muted: color-mix(in srgb, var(--bf-ink) 72%, transparent);
		--bf-card-ink: #ffffff;
		--bf-card-title: color-mix(in srgb, #fff 82%, transparent);
		--bf-card-body: color-mix(in srgb, #fff 62%, transparent);
		--bf-card-line: color-mix(in srgb, #fff 7%, transparent);

		--bf-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		/* 30px at a 390px viewport, 48px from about 1024px up. */
		--bf-display: clamp(1.875rem, 1.1rem + 2.4vw, 3rem);
		--bf-body: 1rem;
		--bf-card-size: 1rem;

		--bf-gutter: 24px;
		--bf-maxw: 1024px;
		--bf-radius: 12px;
		--bf-ease: cubic-bezier(0.16, 1, 0.3, 1);

		box-sizing: border-box;
		display: block;
		position: relative;
		overflow: hidden;
		padding-block: clamp(72px, 9vw, 128px);
		background: var(--bf-surface);
		color: var(--bf-ink);
		font-family: var(--bf-font);
	}

	.bento :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.shell {
		max-width: var(--bf-maxw);
		margin-inline: auto;
		padding-inline: var(--bf-gutter);
	}

	/* ---------- header ---------- */

	.head {
		text-align: center;
	}

	.eyebrow {
		margin: 0;
		font-size: 0.75rem;
		font-weight: 500;
		letter-spacing: 0.2em;
		text-transform: uppercase;
		color: color-mix(in srgb, var(--bf-ink) 75%, transparent);
	}

	.heading {
		margin: 20px 0 0;
		font-size: var(--bf-display);
		line-height: 1.05;
		font-weight: 700;
		letter-spacing: -0.025em;
		text-wrap: balance;
	}

	/*
	 * Each word is its own box so it can rise independently. Inline-block
	 * boxes sit flush against each other — the whitespace between them in the
	 * source collapses away — so the gap between words is put back as a
	 * margin, roughly the width of a space.
	 */
	.word {
		display: inline-block;
		white-space: pre;
	}

	.intro {
		max-width: 42rem;
		margin: 20px auto 0;
		font-size: var(--bf-body);
		line-height: 1.6;
		color: var(--bf-muted);
		text-wrap: pretty;
	}

	/* ---------- grid ---------- */

	.grid {
		display: grid;
		grid-template-columns: 1fr;
		gap: 16px;
		margin-top: 64px;
	}

	.card {
		min-width: 0;
	}

	.card-inner {
		display: flex;
		flex-direction: column;
		height: 100%;
		overflow: hidden;
		border: 1px solid var(--bf-card-line);
		border-radius: var(--bf-radius);
		background: var(--bf-card);
		box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
		transition:
			box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1),
			transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	/*
	 * The text comes first in the source, so a plain column already reads as
	 * "text-first" and it is the other two arrangements that reverse. Keeping
	 * the text ahead of the media in the DOM means a screen reader meets the
	 * claim before the decoration whichever way the card is drawn.
	 */
	.layout-media-first .card-inner {
		flex-direction: column-reverse;
	}

	.card-text {
		padding: 24px;
	}

	.card-head {
		display: flex;
		align-items: center;
		gap: 8px;
		margin-bottom: 6px;
	}

	.card-icon {
		flex-shrink: 0;
		color: var(--bf-card-title);
	}

	.card-title {
		margin: 0;
		font-size: var(--bf-card-size);
		line-height: 1.4;
		font-weight: 600;
		color: var(--bf-card-title);
		transition: color 300ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	.card-body {
		margin: 0;
		padding-left: 24px;
		font-size: 0.875rem;
		line-height: 1.4;
		color: var(--bf-card-body);
		text-wrap: pretty;
	}

	/* ---------- media ---------- */

	.card-media {
		position: relative;
		flex: 1;
		min-height: 10rem;
		overflow: hidden;
		background: #000;
	}

	/* The element that scales, so the panel itself can stay put and clip it. */
	.media-scale {
		position: absolute;
		inset: 0;
		display: grid;
		place-items: center;
		transform-origin: center;
		transition: transform 500ms cubic-bezier(0, 0, 0.2, 1);
		will-change: transform;
	}

	.media-fill {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* Drawn stand-in for a card with no media: nothing to download. */
	.media-scale:has(.placeholder) {
		background:
			radial-gradient(
				ellipse 60% 55% at 50% 42%,
				color-mix(in srgb, var(--bf-accent) 30%, transparent) 0%,
				transparent 72%
			),
			radial-gradient(
				circle at 1px 1px,
				color-mix(in srgb, #fff 8%, transparent) 1px,
				transparent 0
			);
		background-size: cover, 22px 22px;
	}

	.placeholder {
		position: relative;
		font-size: 0.6875rem;
		font-weight: 600;
		letter-spacing: 0.3em;
		color: color-mix(in srgb, #fff 42%, transparent);
	}

	/* ---------- reveal ----------
	 * Hidden in CSS rather than armed by script, so the first paint is already
	 * correct and nothing flashes visible then snaps away.
	 */

	.bento[data-reveal] .word {
		opacity: 0;
		transform: translateY(18px);
		transition:
			opacity 600ms var(--bf-ease) calc(var(--i) * 60ms + 80ms),
			transform 600ms var(--bf-ease) calc(var(--i) * 60ms + 80ms);
	}

	.bento[data-reveal] [data-lift] {
		opacity: 0;
		transform: translateY(18px);
		transition:
			opacity 600ms var(--bf-ease) var(--d, 0ms),
			transform 600ms var(--bf-ease) var(--d, 0ms);
	}

	.bento[data-reveal] [data-card] {
		opacity: 0;
		transform: scale(0.95) translateY(40px);
		transition:
			opacity 700ms var(--bf-ease) var(--d, 0ms),
			transform 700ms var(--bf-ease) var(--d, 0ms);
	}

	.bento[data-reveal] [data-in] .word,
	.bento[data-reveal] [data-lift][data-in],
	.bento[data-reveal] [data-card][data-in] {
		opacity: 1;
		transform: none;
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 768px) {
		.bento {
			--bf-gutter: 24px;
		}

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

		.span-tall,
		.span-full {
			grid-column: span 2;
		}
	}

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

		.span-wide {
			grid-column: span 2;
		}

		.span-tall {
			grid-column: span 2;
			grid-row: span 2;
		}

		.span-full {
			grid-column: span 4;
		}

		/* Only the full-width card has the room to split left and right, with
		   the media leading and the text set beside it. */
		.layout-side-by-side .card-inner {
			flex-direction: row-reverse;
			align-items: stretch;
		}

		.layout-side-by-side .card-text {
			flex: 1;
			align-self: center;
			padding: 32px;
		}

		.layout-side-by-side .card-media {
			flex: 1;
			min-height: 11rem;
		}

		.span-tall .card-text,
		.span-full .card-text {
			padding: 32px;
		}
	}

	/* Hover is a pointer affordance. On a touch screen :hover sticks after the
	   finger lifts, which would leave a card zoomed for no reason. */
	@media (hover: hover) and (pointer: fine) {
		.card:hover .media-scale {
			transform: scale(1.08);
		}

		.card:hover .card-inner {
			box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.14), 0 8px 10px -6px rgb(0 0 0 / 0.14);
		}

		.card:hover .card-title {
			color: var(--bf-card-ink);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.bento[data-reveal] :is(.word, [data-lift], [data-card]) {
			opacity: 1;
			transform: none;
			transition: none;
		}

		.media-scale,
		.card-inner,
		.card-title {
			transition: none;
		}

		/* A zoom on hover is motion the reader asked not to see. */
		.card:hover .media-scale {
			transform: none;
		}
	}
</style>

<noscript>
	<style>
		.bento[data-reveal] :is(.word, [data-lift], [data-card]) {
			opacity: 1;
			transform: none;
		}
	</style>
</noscript>

<script>
	/*
	 * Two behaviours, both built per instance so two grids on a page never
	 * reveal or play each other's cards: the entrance, and hover-to-play.
	 */

	const prefersReduced = () =>
		window.matchMedia("(prefers-reduced-motion: reduce)").matches;

	const setupReveal = (root: HTMLElement) => {
		if (!root.hasAttribute("data-reveal")) return;

		const groups = [
			...root.querySelectorAll<HTMLElement>("[data-words], [data-lift], [data-card]"),
		];
		if (groups.length === 0) return;

		if (!("IntersectionObserver" in window)) {
			groups.forEach((group) => group.setAttribute("data-in", ""));
			return;
		}

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					entry.target.setAttribute("data-in", "");
					observer.unobserve(entry.target);
				}
			},
			/*
			 * The large top margin keeps a jump safe. An observer reports only a
			 * *change* in intersection, so an element that goes from below the
			 * viewport to above it in one jump would read 0 both times, never
			 * fire, and stay invisible for good.
			 */
			{ rootMargin: "10000px 0px -10% 0px", threshold: 0 },
		);

		groups.forEach((group) => observer.observe(group));
	};

	const setupVideos = (root: HTMLElement) => {
		const cards = [...root.querySelectorAll<HTMLElement>("[data-card]")];

		for (const card of cards) {
			const video = card.querySelector<HTMLVideoElement>("[data-video]");
			if (!video) continue;

			/* Playing on hover is already opt-in, but a reader who asked for less
			   motion has opted out of it; the poster stands in. */
			const play = () => {
				if (prefersReduced()) return;
				/* A rejected play() is normal — a background tab, or a policy that
				   wants a gesture first. The poster simply stays. */
				video.play().catch(() => {});
			};
			const stop = () => video.pause();

			card.addEventListener("pointerenter", play);
			card.addEventListener("pointerleave", stop);
			/* Keyboard readers never fire pointer events, so mirror it on focus. */
			card.addEventListener("focusin", play);
			card.addEventListener("focusout", stop);
		}
	};

	for (const root of document.querySelectorAll<HTMLElement>("[data-bento]")) {
		setupReveal(root);
		setupVideos(root);
	}
</script>