Template · React · HTML
Bento product
A product section in Tiles: a large media tile that cross-fades between variants chosen with pills, plus facts beside it.
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-product.jsonnpx shadcn@latest add @manniche/bento-productbento-product.tsx
// Bento product: a product showcase made of tiles. A large media tile (any `ReactNode`, so a Tiles-styled SVG, a
// photo or a render), a tile with the variant switch (`Pills`), a price tile with the price and the one primary
// action, and a row of three or four spec tiles with mono values.
//
// Signature: switching variant cross-fades the media (opacity only, 300 ms) and rolls the price (`BigNumber`). The
// media tile's mono caption names the chosen variant in words, so colour never carries the choice alone.
//
// Screen readers: one `h2` (the product), the variants as a radio group read as "<variantLabel>", and an `img` role on
// the media named by the variant's `mediaLabel`. The price is read once in full. Specs are a description list.
// Reduced motion: the media swaps at once and the price shows its new value without rolling.
import { useEffect, useId, useState, type ComponentProps, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { BigNumber, Pills } from '@/registry/manniche/chart-kit/chart-kit'
import type { ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'
import { DataTile } from '@/registry/manniche/data-tile/data-tile'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'
export type ProductVariant = {
/** A stable key for the variant. */
id: string
/** The variant's name on the switch and in the caption: "Sage". */
label: string
/** The price of this variant. */
price: number
/** What the media shows, read aloud and shown in the caption: "Desk lamp in sage". Default the label. */
mediaLabel?: string
}
export type ProductSpec = {
/** What is measured: "Weight". */
label: string
/** The value, set in mono: "1.2 kg". */
value: string
}
export type BentoProductProps = Omit<ComponentProps<'section'>, 'children' | 'title'> & {
/** The product's name, shown as the section's `h2`. */
name: ReactNode
/** A short line under the name. */
intro?: ReactNode
/** A small mono label above the name, such as the maker. */
eyebrow?: ReactNode
/** The picture: a node, or a function that gets the chosen variant (so the picture can change with it). */
media: ReactNode | ((variant: ProductVariant) => ReactNode)
/** The variants, two to four. */
variants: readonly ProductVariant[]
/** The variant chosen first when `variantId` is not controlled. Default the first. */
defaultVariantId?: string
/** The chosen variant, when you control it. */
variantId?: string
/** Called when the visitor picks another variant. */
onVariantChange?: (id: string) => void
/** The group's name on the switch and its tile: "Colour". */
variantLabel: string
/** The name of the price tile. Default "Price". */
priceLabel?: string
/** The specs, three or four. */
specs: readonly ProductSpec[]
/** How the price is written. Default euros, no decimals. */
priceFormat?: ValueFormat
/** Fine print under the price: "Incl. VAT. Ships in 3 to 5 days." */
priceNote?: ReactNode
/** The one primary action. */
action: { label: string; href: string }
}
export function BentoProduct({
name,
intro,
eyebrow,
media,
variants,
defaultVariantId,
variantId,
onVariantChange,
variantLabel,
specs,
priceLabel = 'Price',
priceFormat = { currency: 'EUR' },
priceNote,
action,
className,
...rest
}: BentoProductProps) {
const headingId = useId()
const reduced = useReducedMotion()
const [own, setOwn] = useState(defaultVariantId ?? variants[0]?.id)
const id = variantId ?? own
const variant = variants.find((v) => v.id === id) ?? variants[0]
// The picture that was showing stays under the new one until the new one has faded in.
const [layers, setLayers] = useState<{ cur: ProductVariant; prev: ProductVariant | null }>({ cur: variant, prev: null })
const [out, setOut] = useState(false)
if (layers.cur.id !== variant.id) {
setLayers({ cur: variant, prev: reduced ? null : layers.cur })
setOut(false)
}
useEffect(() => {
if (!layers.prev) return
const raf = requestAnimationFrame(() => setOut(true))
const end = window.setTimeout(() => setLayers((l) => ({ ...l, prev: null })), 340)
return () => {
cancelAnimationFrame(raf)
window.clearTimeout(end)
}
}, [layers])
const pick = (next: string) => {
setOwn(next)
onVariantChange?.(next)
}
const render = (v: ProductVariant) => (typeof media === 'function' ? media(v) : media)
const spans = specs.length === 3 ? '@3xl:grid-cols-3' : '@3xl:grid-cols-4'
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%' }}
>
{name}
</h2>
{intro && <p className="text-base leading-relaxed text-pretty text-muted-foreground">{intro}</p>}
</header>
<div className="grid grid-cols-1 gap-3 @2xl:gap-4 @3xl:grid-cols-3">
<div className="relative min-h-72 overflow-hidden rounded-[calc(var(--radius)*2+2px)] bg-card shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)] @3xl:col-span-2 @3xl:row-span-2 @3xl:min-h-[28rem]">
<div role="img" aria-label={variant.mediaLabel ?? variant.label} className="absolute inset-0">
{layers.prev && (
<div
aria-hidden
className="absolute inset-0 transition-opacity duration-300 ease-out-quint"
style={{ opacity: out ? 0 : 1 }}
>
{render(layers.prev)}
</div>
)}
<div
key={layers.cur.id}
className={cn('absolute inset-0', layers.prev && 'transition-opacity duration-300 ease-out-quint')}
style={{ opacity: layers.prev && !out ? 0 : 1 }}
>
{render(layers.cur)}
</div>
</div>
<span
aria-hidden
className="absolute bottom-4 left-4 rounded-full bg-card px-3 py-1.5 shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent)] font-mono text-[11.5px] leading-none text-muted-foreground tabular-nums"
>
{variantLabel}: {variant.label}
</span>
</div>
<DataTile title={variantLabel}>
<Pills
label={variantLabel}
options={variants.map((v) => ({ id: v.id, label: v.label }))}
value={variant.id}
onChange={pick}
className="max-w-full"
/>
</DataTile>
<DataTile title={priceLabel}>
<div className="grid gap-5">
<div className="grid gap-1.5">
<BigNumber value={variant.price} format={priceFormat} size="xl" />
{priceNote && <p className="text-[13.5px] leading-normal text-pretty text-muted-foreground">{priceNote}</p>}
</div>
<a
href={action.href}
className="inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-6 text-[15px] font-medium text-primary-foreground transition-opacity duration-150 ease-out-quint hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
{action.label}
</a>
</div>
</DataTile>
<dl className={cn('grid grid-cols-2 gap-3 @2xl:gap-4 @3xl:col-span-3', spans)}>
{specs.map((s) => (
<div
key={s.label}
className="grid content-between gap-6 rounded-[calc(var(--radius)*2+2px)] bg-card p-[18px] shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]"
>
<dt className="text-sm leading-[1.3] font-medium text-muted-foreground">{s.label}</dt>
<dd className="font-mono text-[15px] tracking-[0.01em] break-words tabular-nums">{s.value}</dd>
</div>
))}
</dl>
</div>
</div>
</section>
)
}bento-product-demo.tsx
import { BentoProduct, type ProductVariant } from '@/components/bento-product'
// An invented product for the demo. Colour is never the only signal: each variant is named in words.
const VARIANTS: ProductVariant[] = [
{ id: 'ink', label: 'Ink', price: 189, mediaLabel: 'Halden desk lamp in ink' },
{ id: 'sage', label: 'Sage', price: 189, mediaLabel: 'Halden desk lamp in sage' },
{ id: 'honey', label: 'Honey', price: 209, mediaLabel: 'Halden desk lamp in honey' },
]
const BODY: Record<string, string> = { ink: 'var(--foreground)', sage: 'var(--chart-4)', honey: 'var(--chart-2)' }
function Lamp({ variant }: { variant: ProductVariant }) {
const fill = BODY[variant.id]
return (
// The floor runs far past the view box, so it fills the tile edge to edge and down to the bottom at any aspect
// ratio; the tile's rounded corners clip it.
<svg viewBox="0 0 400 300" className="size-full overflow-visible" preserveAspectRatio="xMidYMid meet" aria-hidden>
<rect x="-2000" y="244" width="4400" height="2000" className="fill-muted" />
<ellipse cx="150" cy="248" rx="62" ry="9" className="fill-foreground/10" />
<rect x="104" y="232" width="92" height="16" rx="8" style={{ fill }} />
<path d="M150 232 V132 L236 66" fill="none" strokeWidth="9" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: fill }} />
<circle cx="150" cy="132" r="8" className="fill-card stroke-foreground/30" strokeWidth="2" />
<path d="M208 52 l54 -16 l22 46 a40 40 0 0 1 -54 16 z" style={{ fill }} />
<path d="M232 100 L300 252" stroke="var(--primary)" strokeOpacity="0.35" strokeWidth="1.5" strokeDasharray="3 6" />
</svg>
)
}
export default function BentoProductDemo() {
return (
<BentoProduct
eyebrow="Halden Studio, Munich"
name="The Halden desk lamp."
intro="A lamp that does one thing well. Example product for a demo company."
variantLabel="Colour"
variants={VARIANTS}
media={(v) => <Lamp variant={v} />}
specs={[
{ label: 'Weight', value: '1.2 kg' },
{ label: 'Height', value: '48 cm, arm folds' },
{ label: 'Light', value: '2700 K, dimmable' },
{ label: 'Cable', value: '1.8 m, textile' },
]}
priceNote="Incl. VAT. Ships in 3 to 5 working days."
action={{ label: 'Add to basket', href: 'https://example.com/basket' }}
/>
)
}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-product.json