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.