Template · React · HTML
Bento features
A features section as a bento grid of Tiles, with one inverted lead tile for the feature that matters most.
Preview
React
Install the template with shadcn. It lands in your project as ordinary components that you edit yourself.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/bento-features.jsonnpx shadcn@latest add @manniche/bento-featuresbento-features.tsx
// Bento features: a section heading over a bento of feature tiles. The first feature is the lead tile: it spans two
// columns and two rows on wide boxes and is the inverted tile, so the one thing the page is about is lifted. Every
// tile has a title, one sentence and a visual slot you fill with whatever you like (DOM, SVG, a figure).
//
// How it reads: one `h2` for the section, a real list of features, and each tile is its own labelled region with an
// `h3`. The visual slot is decorative by default and hidden from screen readers; the sentence carries the meaning.
// Layout follows the box the section sits in (container queries): one column, then two, then a 3 x 3 bento.
// Motion: none of its own. Visuals you pass in decide that and should respect reduced motion.
import { useId, type ComponentProps, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { DataTile } from '@/registry/manniche/data-tile/data-tile'
export type BentoFeature = {
/** A stable key for the tile. */
id: string
/** The tile's name, shown as an `h3`. */
title: ReactNode
/** One sentence that says what the feature does for the reader. */
description: ReactNode
/** A small visual for the tile: DOM, SVG or a figure. Hidden from screen readers unless `visualLabel` is set. */
visual?: ReactNode
/** Names the visual for screen readers. Leave it out when the visual only decorates the sentence. */
visualLabel?: string
}
export type BentoFeaturesProps = Omit<ComponentProps<'section'>, 'children' | 'title'> & {
/** The section's `h2`. */
heading: ReactNode
/** A short line under the heading. */
intro?: ReactNode
/** A small mono label above the heading, such as "Features". */
eyebrow?: ReactNode
/** The features, lead first. Six fill the bento exactly; other counts reflow without holes at the ends. */
features: readonly BentoFeature[]
}
export function BentoFeatures({ heading, intro, eyebrow, features, className, ...rest }: BentoFeaturesProps) {
const headingId = useId()
return (
<section aria-labelledby={headingId} className={cn('@container w-full py-12 @3xl:py-20', className)} {...rest}>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<header className="mb-8 grid max-w-2xl gap-3 @3xl:mb-12">
{eyebrow && <p className="font-mono text-xs tracking-[0.04em] text-muted-foreground tabular-nums">{eyebrow}</p>}
<h2
id={headingId}
className="text-[clamp(30px,5.4cqw,52px)] leading-[1.02] font-extrabold tracking-[-0.035em] text-balance text-foreground"
style={{ fontFamily: 'var(--font-display, inherit)', fontStretch: '86%' }}
>
{heading}
</h2>
{intro && <p className="text-base leading-relaxed text-pretty text-muted-foreground">{intro}</p>}
</header>
<ul className="grid list-none grid-cols-1 gap-3 p-0 @2xl:grid-cols-2 @2xl:gap-4 @4xl:grid-cols-3">
{features.map((f, i) => {
const lead = i === 0
return (
<li
key={f.id}
className={cn(
'flex min-w-0',
lead && '@2xl:col-span-2 @4xl:row-span-2',
// An odd tile at the end of the two-up layout takes the full row; the 3-up layout needs no help.
i === features.length - 1 && i % 2 === 1 && '@2xl:col-span-2 @4xl:col-span-1',
)}
>
<DataTile title={f.title} inverted={lead} className="w-full">
<div className={cn('flex h-full flex-col gap-5', lead && '@4xl:gap-8')}>
{f.visual && (
<div
role={f.visualLabel ? 'img' : undefined}
aria-label={f.visualLabel}
aria-hidden={f.visualLabel ? undefined : true}
className={cn(
'relative min-h-32 flex-1 overflow-hidden rounded-[14px] bg-muted',
lead && 'min-h-48 @4xl:min-h-72',
)}
>
{f.visual}
</div>
)}
<p
className={cn(
'text-pretty text-muted-foreground',
lead ? 'max-w-prose text-base leading-relaxed' : 'text-[15px] leading-normal',
)}
>
{f.description}
</p>
</div>
</DataTile>
</li>
)
})}
</ul>
</div>
</section>
)
}bento-features-demo.tsx
import { BentoFeatures, type BentoFeature } from '@/components/bento-features'
// Small Tiles-styled illustrations made of DOM and SVG. They only decorate the sentences next to them.
const bar = 'rounded-full bg-foreground/15'
function Board() {
return (
<div className="absolute inset-0 grid grid-cols-3 gap-2 p-3 @4xl:gap-3 @4xl:p-5">
{[3, 2, 1].map((n, col) => (
<div key={col} className="grid content-start gap-2 rounded-[10px] bg-card/60 p-2">
<span className="h-2 w-8 rounded-full bg-foreground/30" />
{Array.from({ length: n + 1 }).map((_, i) => (
<span
key={i}
className={`block rounded-[8px] bg-card p-2 shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent)] ${col === 1 && i === 0 ? 'outline-2 outline-primary' : ''}`}
>
<span className={`${bar} block h-1.5 w-10/12`} />
<span className={`${bar} mt-1.5 block h-1.5 w-1/2`} />
</span>
))}
</div>
))}
</div>
)
}
function Review() {
return (
<div className="absolute inset-0 grid content-center gap-2 p-4">
{[70, 90, 55].map((w, i) => (
<span key={i} className="flex items-center gap-2">
<span className="size-5 flex-none rounded-full bg-foreground/20 font-mono text-[10px] leading-5 text-center">{i + 1}</span>
<span className={`${bar} h-2`} style={{ width: `${w}%` }} />
</span>
))}
</div>
)
}
function Handover() {
return (
<svg viewBox="0 0 160 72" className="absolute inset-0 size-full p-4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<rect x="6" y="18" width="44" height="36" rx="8" className="text-foreground/40" />
<rect x="110" y="18" width="44" height="36" rx="8" className="text-foreground/40" />
<path d="M56 36h42M92 30l6 6-6 6" className="text-foreground" />
</svg>
)
}
function Roadmap() {
return (
<div className="absolute inset-0 grid content-center gap-2.5 p-4 font-mono text-[11px] text-muted-foreground tabular-nums">
{[
['Mar', 40, 32],
['Apr', 24, 0],
['May', 16, 48],
].map(([m, w, x]) => (
<span key={m} className="grid grid-cols-[2rem_1fr] items-center gap-2">
{m}
<span className="relative h-2 rounded-full bg-foreground/10">
<span className="absolute inset-y-0 rounded-full bg-foreground/60" style={{ width: `${w}%`, left: `${x}%` }} />
</span>
</span>
))}
</div>
)
}
function Notes() {
return (
<div className="absolute inset-0 grid content-center gap-2 p-4">
<span className={`${bar} h-2 w-full`} />
<span className={`${bar} h-2 w-10/12`} />
<span className="flex items-center gap-1.5">
<span className={`${bar} h-2 w-5/12`} />
<span className="h-4 w-px bg-primary" />
</span>
</div>
)
}
function Archive() {
return (
<div className="absolute inset-0 flex items-center justify-center gap-2 p-4 font-mono text-[11px] text-muted-foreground tabular-nums">
{['v1', 'v2', 'v3'].map((v, i) => (
<span key={v} className={`rounded-full px-3 py-1 ${i === 2 ? 'bg-foreground text-background' : 'bg-card'}`}>
{v}
</span>
))}
</div>
)
}
const FEATURES: BentoFeature[] = [
{
id: 'board',
title: 'One board for the whole project',
description:
'Briefs, drafts and decisions sit in the same place as the work, so nobody in Munich or Aalborg has to ask where the latest version lives.',
visual: <Board />,
},
{ id: 'review', title: 'Review in order', description: 'Comments land in the order they were made, one owner each.', visual: <Review /> },
{ id: 'handover', title: 'Clean handovers', description: 'Hand a task over with its context attached.', visual: <Handover /> },
{ id: 'roadmap', title: 'A plan you can read', description: 'Weeks on one line each, with nothing hidden in a sub-menu.', visual: <Roadmap /> },
{ id: 'notes', title: 'Notes beside the work', description: 'Write next to the thing you are writing about.', visual: <Notes /> },
{ id: 'archive', title: 'Every version kept', description: 'Go back to any earlier version without asking anyone.', visual: <Archive /> },
]
export default function BentoFeaturesDemo() {
return (
<BentoFeatures
eyebrow="Halden Studio"
heading="Everything a small studio needs to ship together."
intro="Six tools that stay out of the way. Example copy for a demo company."
features={FEATURES}
/>
)
}HTML
The whole page as one file with all CSS built in and no JavaScript. Toggles, tabs and filters work with HTML and CSS alone.
The colour in your own app
If you already have shadcn set up, these variables give the same colour. Light in :root, dark in .dark.
:root {
--primary: oklch(0.52 0.2 262);
--primary-foreground: oklch(0.99 0 0);
--accent: oklch(0.95 0.034 262);
--accent-foreground: oklch(0.36 0.140 262);
--ring: oklch(0.52 0.2 262);
}
.dark {
--primary: oklch(0.74 0.150 262);
--primary-foreground: oklch(0.18 0.024 262);
--accent: oklch(0.29 0.050 262);
--accent-foreground: oklch(0.9 0.050 262);
--ring: oklch(0.74 0.150 262);
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/bento-features.json