Three ways to get Bento feature grid into one of your own projects, all pre-filled with this component's name and library page.
Start here
Component reference
Paste it into a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving the branding and content open.
Read it first
“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.
ChatGPT brief
Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.
Read it first
Help me add this component to one of my Astro websites:
Component: Bento feature grid
Library page: https://astro.baysixmedia.com/components/bento-feature-grid
I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]
Place it:
[ENTER LOCATION OR SELECTOR]
Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]
Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.
Placement prompt
Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.
Read it first
Add Bento feature grid from my Astro Component Library to this Astro project.
Library page:
https://astro.baysixmedia.com/components/bento-feature-grid
First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.
Place it in:
[ENTER PAGE, FILE, OR ROUTE]
Position:
[ENTER WHERE IT SHOULD APPEAR]
Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.
Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.
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 defaultCards: BentoCard[] = [
{
title: "We read the system first",
body: "Before anything is drawn we go through what already exists — the components, the tokens, the places two teams solved the same problem twice.",
layout: "text-first",
span: "tall",
placeholderLabel: "AUDIT",
icon: "M4 6h16M4 12h10M4 18h7",
},
{
title: "One team, one thread",
body: "No handoff between the people who decided and the people who built. You talk to whoever is doing the work.",
layout: "media-first",
span: "wide",
placeholderLabel: "TEAM",
icon: "M16 19a4 4 0 0 0-8 0M12 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z",
},
{
title: "The smallest thing that works",
body: "Sometimes the answer is one component. Sometimes it is a system. We say which, even when the smaller answer is the cheaper invoice.",
layout: "media-first",
span: "wide",
placeholderLabel: "SCOPE",
icon: "M12 5v14M5 12h14",
},
{
title: "It still works after we go",
body: "Everything ships with its own README, its own demo and no dependency on us. You own it, you can read it, and it survives the handover.",
layout: "side-by-side",
span: "full",
placeholderLabel: "HANDOVER",
icon: "M9 12l2 2 4-4M12 21a9 9 0 1 1 0-18 9 9 0 0 1 0 18Z",
},
];
const {
eyebrow = "The difference",
heading = "Why it matters after launch",
intro = "The system behind a product is not a detail. It is the difference between a thing that ships once and a thing a team can keep changing.",
cards = defaultCards,
headingLevel = "h2",
surface = "#f7f7f8",
cardSurface = "#0a0a0b",
ink = "#2a2724",
accent = "#5b6ee0",
reveal = true,
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
/* 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";
---
<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">
<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>