Skabelon · React · HTML
Bento features
En sektion med funktioner som et bento-gitter af fliser, hvor den vigtigste funktion står i én mørk flise.
Forhåndsvisning
React
Installér skabelonen med shadcn. Den lægges i dit projekt som almindelige komponenter, som du selv retter i.
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
Hele siden som én fil med al CSS indbygget og intet JavaScript. Knapper, faner og filtre virker med HTML og CSS alene.
Farven i din egen app
Har du allerede shadcn sat op, giver disse variabler samme farve. Lys i :root, mørk i .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ér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/bento-features.json