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.jsonnpx shadcn@latest add @manniche/bento-stepsbento-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.
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