Mikkel Manniche
Dansk English Deutsch

Skabelon · React · HTML

Bento steps

En sektion om, hvordan det virker: nummererede trin i fliser forbundet af en linje, hvor færdig, nu og næste også står i ord.

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-steps.json
npx shadcn@latest add @manniche/bento-steps
bento-steps.tsx
// Bento steps: a process in three to five tiles. Each tile has a mono number ("01"), a title, a sentence and an
// optional duration chip. On wide boxes the steps sit in one row; on narrow ones they stack in a vertical list with a
// thin line joining the tiles. `current` marks where the reader is: steps before it say "Done" with a check mark, the
// current one says "Now" with the page's one `--primary` dot and ring, and the rest are quiet.
//
// Screen readers: one `h2` and an ordered list (`<ol>`), so the count and position are announced. Each step reads
// its number, title and sentence, then its state in words ("Done", "Now", "Up next"); the current step also has
// `aria-current="step"`. The check mark, dot and connecting line are hidden from them.
// Motion: none. Nothing animates, so reduced motion changes nothing.
import { useId, type ComponentProps, type ReactNode } from 'react'
import { cn } from '@/lib/utils'

export type BentoStep = {
  /** A stable key for the step. */
  id: string
  /** The step's name, shown as an `h3`. */
  title: ReactNode
  /** One sentence about what happens in this step. */
  description: ReactNode
  /** How long the step takes, as a short chip: "1 week", "2 days". */
  duration?: string
}

export type BentoStepsLabels = {
  /** Shown on steps before the current one. Default "Done". */
  done?: string
  /** Shown on the current step. Default "Now". */
  now?: string
  /** Read aloud before each step number. Not shown. Default "Step". */
  step?: string
  /** Read aloud on the steps after the current one. Not shown. Default "Up next". */
  upcoming?: string
}

export type BentoStepsProps = 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. */
  eyebrow?: ReactNode
  /** The steps, three to five. */
  steps: readonly BentoStep[]
  /** The index (from 0) of the step that is happening now. Steps before it are done. Leave it out to show no progress. */
  current?: number
  /** Every visible and spoken string that is not content. */
  labels?: BentoStepsLabels
}

const COLS: Record<number, string> = {
  2: '@3xl:grid-cols-2',
  3: '@3xl:grid-cols-3',
  4: '@3xl:grid-cols-4',
  5: '@3xl:grid-cols-5',
}

const DEFAULT_LABELS = { step: 'Step', done: 'Done', now: 'Now', upcoming: 'Up next' } satisfies Required<BentoStepsLabels>

export function BentoSteps({ heading, intro, eyebrow, steps, current, labels, className, ...rest }: BentoStepsProps) {
  const headingId = useId()
  const t = { ...DEFAULT_LABELS, ...labels }
  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>
        <ol className={cn('grid list-none grid-cols-1 gap-6 p-0 @3xl:gap-4', COLS[steps.length] ?? '@3xl:grid-cols-4')}>
          {steps.map((s, i) => {
            const done = current !== undefined && i < current
            const now = current === i
            const state = done ? t.done : now ? t.now : null
            return (
              <li
                key={s.id}
                aria-current={now ? 'step' : undefined}
                className="relative flex min-w-0 after:absolute after:top-full after:left-[34px] after:h-6 after:w-px after:bg-border last:after:hidden @3xl:after:hidden data-[done]:after:bg-foreground/40"
                data-done={done || undefined}
              >
                <div
                  className={cn(
                    'flex w-full min-w-0 flex-col gap-4 rounded-[calc(var(--radius)*2+2px)] bg-card p-[18px] text-card-foreground @3xl:p-6',
                    now
                      ? 'shadow-[0_0_0_2px_var(--primary)]'
                      : 'shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]',
                  )}
                >
                  <div className="flex min-h-7 items-center justify-between gap-3">
                    <span className="font-mono text-[12px] tracking-[0.04em] text-muted-foreground tabular-nums">
                      <span className="sr-only">{t.step} </span>
                      {String(i + 1).padStart(2, '0')}
                    </span>
                    {state ? (
                      <span className="inline-flex items-center gap-1.5 font-mono text-[11px] tracking-[0.04em] text-foreground uppercase">
                        {done ? (
                          <svg
                            viewBox="0 0 12 12"
                            aria-hidden
                            className="size-3 fill-none stroke-current stroke-2 [stroke-linecap:round] [stroke-linejoin:round]"
                          >
                            <path d="m2.5 6.5 2.5 2.5 4.5-5.5" />
                          </svg>
                        ) : (
                          <i aria-hidden className="size-2 rounded-full bg-primary" />
                        )}
                        {state}
                      </span>
                    ) : (
                      current !== undefined && <span className="sr-only">{t.upcoming}</span>
                    )}
                  </div>
                  <div className="grid gap-2">
                    <h3 className="text-[19px] leading-[1.15] font-semibold tracking-[-0.02em] text-balance">{s.title}</h3>
                    <p className="text-[15px] leading-normal text-pretty text-muted-foreground">{s.description}</p>
                  </div>
                  {s.duration && (
                    <span className="mt-auto w-fit rounded-full bg-muted px-2.5 py-1 font-mono text-[11.5px] leading-none text-muted-foreground tabular-nums">
                      {s.duration}
                    </span>
                  )}
                </div>
              </li>
            )
          })}
        </ol>
      </div>
    </section>
  )
}
bento-steps-demo.tsx
import { useState } from 'react'
import { BentoSteps, type BentoStep } from '@/components/bento-steps'
import { Pills } from '@/components/chart-kit'

const STEPS: BentoStep[] = [
  { id: 'brief', title: 'Brief', description: 'We listen, read what you have and agree what done looks like.', duration: '2 days' },
  { id: 'sketch', title: 'Sketch', description: 'Three rough directions on one page, so choosing is quick.', duration: '1 week' },
  { id: 'build', title: 'Build', description: 'The chosen direction becomes a working page you can click through.', duration: '3 weeks' },
  { id: 'review', title: 'Review', description: 'One round of notes from everyone, gathered in one place.', duration: '1 week' },
  { id: 'launch', title: 'Launch', description: 'We publish, check it on real devices and hand over the files.', duration: '2 days' },
]

const OPTIONS = STEPS.map((_, i) => ({ id: String(i), label: String(i + 1) }))

export default function BentoStepsDemo() {
  const [current, setCurrent] = useState('2')
  return (
    <div className="w-full">
      <BentoSteps
        eyebrow="Halden Studio, how a project runs"
        heading="Five steps, one at a time."
        intro="Example process for a demo company."
        steps={STEPS}
        current={Number(current)}
      />
      <div className="mx-auto flex max-w-6xl flex-wrap items-center gap-3 px-4 pb-8 sm:px-6">
        <span className="font-mono text-xs text-muted-foreground">Current step</span>
        <Pills label="Current step" options={OPTIONS} value={current} onChange={setCurrent} />
      </div>
    </div>
  )
}

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-steps.json

Afhængigheder

    Filer

    • bento-steps.tsx
    • bento-steps.html

    Manniche Lab · open source

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