Bento product
templatesGratisMITEn produktsektion i Tiles: en stor billedflise, der toner mellem varianter valgt med piller, og fakta ved siden af.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/bento-product.jsonPreview
Den rene HTML-fil. Farve og tema skifter i rammen.
Kode
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-product.jsonnpx shadcn@latest add @manniche/bento-product// 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>
)
}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
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);
}Props
BentoProductProps
| Prop | Type | Standard | Hvad den gør |
|---|---|---|---|
name påkrævet | ReactNode | – | The product's name, shown as the section's h2. |
intro | ReactNode | – | A short line under the name. |
eyebrow | ReactNode | – | A small mono label above the name, such as the maker. |
media påkrævet | ReactNode | ((variant: ProductVariant) => ReactNode) | – | The picture: a node, or a function that gets the chosen variant (so the picture can change with it). |
variants påkrævet | readonly ProductVariant[] | – | The variants, two to four. |
defaultVariantId | string | – | The variant chosen first when variantId is not controlled. Default the first. |
variantId | string | – | The chosen variant, when you control it. |
onVariantChange | (id: string) => void | – | Called when the visitor picks another variant. |
variantLabel påkrævet | string | – | The group's name on the switch and its tile: "Colour". |
priceLabel | string | 'Price' | The name of the price tile. Default "Price". |
specs påkrævet | readonly ProductSpec[] | – | The specs, three or four. |
priceFormat | ValueFormat | { currency: 'EUR' } | How the price is written. Default euros, no decimals. |
priceNote | ReactNode | – | Fine print under the price: "Incl. VAT. Ships in 3 to 5 days." |
action påkrævet | { label: string; href: string } | – | The one primary action. |
Tager også alle props fra Omit<ComponentProps<'section'>, 'children' | 'title'>.
A11y
Skærmlæser
A section named by its h2. The product image is role="img" with the variant mediaLabel (or label); the visible caption is aria-hidden. The variants are a Pills radio group from chart-kit, named by variantLabel; the price is read once in full, and the specs are a description list.
Tastatur
Variants use the Pills arrow keys, with Home and End; the action is a link.
Mindre bevægelse
Media layers cross-fade on a variant change; under reduced motion the media swaps at once and the price does not roll.
Detaljer
Afhængigheder
Ingen ud over React og Tailwind.