Website Sections
FeatureBento
Tiles of mixed sizes on a 6-column grid. They rise in on scroll; on hover the tile lifts and its media grows. One column when narrow.
Download
- React
- Tailwind CSS v4
- motion
components/ui/feature-bento.tsx
"use client";
import { useId, type CSSProperties, type ElementType, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
export type FeatureBentoItem = {
title: string;
text: string;
/** A picture or small live demo at the top of the tile. Position it absolute to fill. */
media?: ReactNode;
/** small = 2 of 6 columns, wide = 4 columns, tall = 2 columns and 2 rows, full = 6 columns. */
size?: "small" | "wide" | "tall" | "full";
/** Makes the whole tile a link. */
href?: string;
};
type FeatureBentoProps = {
items: FeatureBentoItem[];
title?: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Tile colour. */
tile?: string;
/** Tile edge colour on hover. */
color?: string;
/** h2 by default. */
headingAs?: ElementType;
/** Set padding and colours here. */
className?: string;
};
const SIZES = {
small: "@3xl:col-span-2",
wide: "@3xl:col-span-4",
tall: "@3xl:col-span-2 @3xl:row-span-2",
full: "@3xl:col-span-6",
};
/* FeatureBento: tiles of mixed sizes on a 6-column grid. They rise in on scroll; on hover the tile lifts and its media grows. One column when narrow. */
export function FeatureBento({
items,
title,
eyebrow,
subtitle,
tile = "#15122a",
color = "#a78bfa",
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16 text-white",
}: FeatureBentoProps) {
const reduce = useReducedMotion();
const id = useId();
return (
<section aria-labelledby={title ? id : undefined} className={`@container ${className}`}>
{title && (
<header className="mx-auto mb-8 max-w-2xl text-center">
{eyebrow && <p className="mb-2 text-xs font-medium tracking-[0.2em] uppercase opacity-70">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.5rem,6cqw,2.5rem)] leading-tight font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 text-pretty opacity-70">{subtitle}</p>}
</header>
)}
<ul className="mx-auto grid max-w-6xl grid-cols-1 gap-3 [grid-auto-flow:dense] @3xl:auto-rows-[minmax(13rem,auto)] @3xl:grid-cols-6 @3xl:gap-4">
{items.map((item, i) => {
const body = (
<>
{item.media && (
<div className="relative min-h-28 flex-1 overflow-hidden transition-transform duration-500 ease-out group-hover/tile:scale-[1.04] motion-reduce:transition-none motion-reduce:group-hover/tile:scale-100">
{item.media}
</div>
)}
<div className="p-4">
<span className="block font-semibold">{item.title}</span>
<span className="mt-1 block text-sm text-pretty opacity-70">{item.text}</span>
</div>
</>
);
return (
<motion.li
key={item.title}
className={`group/tile overflow-hidden rounded-2xl border border-current/10 transition-[border-color,translate,box-shadow] duration-300 hover:-translate-y-1 hover:border-[var(--fb-edge)] hover:shadow-[0_18px_40px_-24px_var(--fb-edge)] focus-within:border-[var(--fb-edge)] motion-reduce:hover:translate-y-0 ${SIZES[item.size ?? "small"]}`}
style={{ backgroundColor: tile, "--fb-edge": color } as CSSProperties}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 20 }}
whileInView={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15 }}
transition={{ duration: 0.6, delay: Math.min(i, 6) * 0.06, ease: [0.16, 1, 0.3, 1] }}
>
{item.href ? (
<a href={item.href} className="flex h-full flex-col">
{body}
</a>
) : (
<div className="flex h-full flex-col">{body}</div>
)}
</motion.li>
);
})}
</ul>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/feature-bento.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/feature-bento-demo.tsx
import { FeatureBento } from "@/components/ui/feature-bento"; const BARS = [40, 72, 55, 90, 64, 100, 78]; const ITEMS = [ { title: "Live numbers", text: "Every chart updates as sales come in.", size: "wide" as const, media: ( <div className="absolute inset-x-4 top-5 bottom-0 flex items-end gap-2"> {BARS.map((bar, i) => ( <span key={i} className="flex-1 rounded-t bg-gradient-to-t from-[#7c3aed] to-[#a78bfa]" style={{ height: `${bar}%` }} /> ))} </div> ), }, { title: "Coastal mode", text: "A calm theme for long days.", size: "tall" as const, media: <img src="/demo/tide.jpg" alt="" className="absolute inset-0 size-full object-cover" />, }, { title: "Shared notes", text: "Comment right on the page." }, { title: "Warm exports", text: "PDFs your clients want to read.", media: <img src="/demo/ember.jpg" alt="" className="absolute inset-0 size-full object-cover" />, }, ]; export default function FeatureBentoExample() { return ( <div className="h-full overflow-y-auto"> <FeatureBento title="One workspace, every view" items={ITEMS} headingAs="h3" className="min-h-full bg-[#0b0a14] p-4 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | FeatureBentoItem[] | — | Each has title and text, plus optional media, size and href. size: small, wide, tall or full. |
| title | string | — | Heading above the tiles. |
| eyebrow | string | — | Small line above the title. |
| subtitle | string | — | Line under the title. |
| tile | string | "#15122a" | Tile colour. |
| color | string | "#a78bfa" | Tile edge colour on hover. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16 text-white" | Set padding and colours here. |