Template · React · HTML
Bento steps
A how-it-works section: numbered steps in Tiles joined by a connector line, with done, now and up next read out in words.
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-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
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-steps.json