Mikkel Manniche
Dansk English Deutsch

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.json
npx shadcn@latest add @manniche/bento-features
bento-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.

Åbn i ny fane

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

Afhængigheder

    Filer

    • bento-features.tsx
    • bento-features.html

    Manniche Lab · open source

    Gratis værktøjer til AI-agenter og webudvikling.