Process Timeline
Make your process easy to understand: readable stages, clear deliverables, and a visual that follows each phase as visitors scroll.
HOW WE GET THERE, TOGETHER.Scroll through the four stages ↓
A good process. A better outcome.
Discover
We ask the right questions, get to know your audience, and agree on what success looks like before the first pixel.
- Workshop
- Audience insights
- Project brief
Design
We shape the story, explore a visual direction, and turn it into a prototype your team can click through and discuss.
- Art direction
- UI system
- Clickable prototype
Build
The approved design becomes a fast, responsive website. We connect your content and check the details on real screens.
- Development
- CMS setup
- Device testing
Launch
We make the final checks, show your team around, and get the site into the world with a clear plan for what comes next.
- Quality assurance
- Team training
- Launch support
ONE CLEAR DIRECTION. EVERY STEP OF THE WAY. ↗
'use client'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import './process-timeline.css'
export interface ProcessTimelineStep {
id: string
title: string
description: string
duration?: string
deliverables?: string[]
/** Decorative artwork. Put essential information in the text fields. */
media: ReactNode
}
export interface ProcessTimelineProps {
steps: ProcessTimelineStep[]
title?: string
className?: string
}
function decorativeMedia(element: HTMLDivElement | null) {
element?.setAttribute('inert', '')
}
function scrollParent(element: HTMLElement) {
let parent = element.parentElement
while (parent) {
if (/(auto|scroll|overlay)/.test(window.getComputedStyle(parent).overflowY)) return parent
parent = parent.parentElement
}
return null
}
/** A readable project process with a scroll-following decorative preview. */
export function ProcessTimeline({ steps, title = 'From first thought to launch', className = '' }: ProcessTimelineProps) {
const headingId = useId()
const rootRef = useRef<HTMLElement>(null)
const stepRefs = useRef<(HTMLLIElement | null)[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
const activeIndex = Math.max(0, steps.findIndex((step) => step.id === activeId))
const activeStep = steps[activeIndex]
useEffect(() => {
const element = rootRef.current
if (!element || !steps.length) return
const viewport = scrollParent(element)
const scrollTarget = viewport ?? window
let frame = 0
function readPosition() {
frame = 0
const bounds = viewport?.getBoundingClientRect()
const top = Math.max(0, bounds?.top ?? 0)
const bottom = Math.min(window.innerHeight, bounds?.bottom ?? window.innerHeight)
if (bottom <= top) return
const componentBounds = element!.getBoundingClientRect()
if (componentBounds.bottom < top || componentBounds.top > bottom) return
const target = top + Math.min(80, (bottom - top) * 0.22)
let next = 0
for (let index = 0; index < steps.length; index += 1) {
const step = stepRefs.current[index]
if (step && step.getBoundingClientRect().top <= target) next = index
}
// A short final stage may never reach the top threshold before the scroll ends.
const lastBounds = stepRefs.current[steps.length - 1]?.getBoundingClientRect()
if (lastBounds && lastBounds.top < bottom && lastBounds.bottom <= bottom + 1) next = steps.length - 1
setActiveId((current) => current === steps[next].id ? current : steps[next].id)
}
function scheduleRead() {
if (!frame) frame = window.requestAnimationFrame(readPosition)
}
readPosition()
scrollTarget.addEventListener('scroll', scheduleRead, { passive: true })
// Page scrolling can move a nested viewport into or out of view.
if (viewport) window.addEventListener('scroll', scheduleRead, { passive: true })
window.addEventListener('resize', scheduleRead)
const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(scheduleRead)
resize?.observe(element)
if (viewport) resize?.observe(viewport)
return () => {
scrollTarget.removeEventListener('scroll', scheduleRead)
if (viewport) window.removeEventListener('scroll', scheduleRead)
window.removeEventListener('resize', scheduleRead)
resize?.disconnect()
window.cancelAnimationFrame(frame)
}
}, [steps])
return (
<section ref={rootRef} className={`ac-process ${className}`.trim()} aria-labelledby={headingId}>
<header className="ac-process-heading">
<div><span className="ac-process-eyebrow">THE PROCESS</span><h2 id={headingId}>{title}</h2></div>
<span className="ac-process-total">{steps.length.toString().padStart(2, '0')} stages</span>
</header>
{activeStep ? <div className="ac-process-layout">
<ol className="ac-process-steps">
{steps.map((step, index) => <li
key={step.id}
ref={(element) => { stepRefs.current[index] = element }}
className="ac-process-step"
aria-current={activeIndex === index ? 'step' : undefined}
>
<div className="ac-process-step-meta"><span className="ac-process-number" aria-hidden="true">{(index + 1).toString().padStart(2, '0')}</span>{step.duration && <span>{step.duration}</span>}</div>
<h3>{step.title}</h3>
<p>{step.description}</p>
{!!step.deliverables?.length && <ul className="ac-process-deliverables" aria-label={`${step.title} deliverables`}>{step.deliverables.map((deliverable, itemIndex) => <li key={`${itemIndex}-${deliverable}`}>{deliverable}</li>)}</ul>}
<div ref={decorativeMedia} className="ac-process-inline-art" aria-hidden="true">{step.media}</div>
</li>)}
</ol>
<div ref={decorativeMedia} className="ac-process-preview" aria-hidden="true">
<div key={activeStep.id} className="ac-process-preview-art">{activeStep.media}</div>
<div className="ac-process-preview-caption"><span>{activeStep.title}</span><span>{(activeIndex + 1).toString().padStart(2, '0')} / {steps.length.toString().padStart(2, '0')}</span></div>
<div className="ac-process-progress"><span style={{ width: `${((activeIndex + 1) / steps.length) * 100}%` }} /></div>
</div>
</div> : <p className="ac-process-empty">The next chapter is taking shape. Process details are coming soon.</p>}
</section>
)
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/process-timeline.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { ProcessTimeline } from '@/components/anticraft/ProcessTimeline';
export function Example() {
return (
<ProcessTimeline
title="From first thought to launch"
steps={[
{
id: 'discover', title: 'Discover', duration: '1 week',
description: 'Understand the audience, define the brief, and agree on what success looks like.',
deliverables: ['Workshop', 'Project brief'],
media: <div style={{ display: 'grid', placeItems: 'center', background: '#bfcca5', color: '#39482e', fontFamily: 'Georgia, serif', fontSize: 46 }}>Ask better questions.</div>,
},
{
id: 'design', title: 'Design', duration: '2 weeks',
description: 'Explore the visual direction and make a prototype your team can click through.',
deliverables: ['Art direction', 'Clickable prototype'],
media: <div style={{ display: 'grid', placeItems: 'center', background: '#d47b5c', color: '#573829', fontFamily: 'Georgia, serif', fontSize: 46 }}>Room for better.</div>,
},
{
id: 'build', title: 'Build', duration: '2 weeks',
description: 'Build a responsive website, connect your content, and test on real screens.',
deliverables: ['Development', 'Device testing'],
media: <div style={{ display: 'grid', placeItems: 'center', background: '#343d34', color: '#c8d79f', fontFamily: 'monospace', fontSize: 38 }}><MadeReal /></div>,
},
{
id: 'launch', title: 'Launch', duration: '1 week',
description: 'Make the final checks, train your team, and get your site into the world.',
deliverables: ['Quality assurance', 'Team training'],
media: <div style={{ display: 'grid', placeItems: 'center', background: '#cecee0', color: '#44415e', fontFamily: 'Georgia, serif', fontSize: 46 }}>Ready for what’s next.</div>,
},
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
steps | ProcessTimelineStep[] | Required | Ordered stages with a unique id, title, description and decorative ReactNode media. Every stage’s description stays in the document. Optional duration and deliverables add useful scope. Empty lists show a short empty state; replaced lists fall back to their first available stage until the next scroll measurement. |
title | string | 'From first thought to launch' | The section heading and accessible name. |
step.duration | string | undefined | A short time estimate such as 1 week or Weeks 2–3, displayed beside the stage number. |
step.deliverables | string[] | undefined | Concrete outputs for a stage, presented as a labeled list. All deliverables remain readable; they are never replaced by the decorative scroll preview. |
step.media | ReactNode | Required | Decorative imagery or artwork with no links or controls. Both desktop and inline copies are hidden from assistive technology and made inert. Images should use alt="". Put essential details in title, description and deliverables. The desktop artwork fills a 4:5 surface; compact inline artwork is 220px high (190px below 300px component width). |
className | string | '' | Additional classes. Customize --ac-process-ink, --ac-process-muted, --ac-process-line, --ac-process-accent, --ac-process-surface and --ac-process-radius. At a component width of 560px or narrower, artwork appears inside every stage and the side preview disappears. |
Motion & accessibility
Scrolling marks the current stage and updates a decorative side preview. It follows the nearest vertically scrollable ancestor, or the page when there is none, using passive events and one scheduled measurement per frame rather than a continuous animation loop. The right panel sticks 16px from the scroll viewport’s top and stops at the end of this section, so the following footer remains reachable; allow enough viewport height for the preview. All stage descriptions and deliverables remain visible in a semantic ordered list. Compact layouts show each illustration inline. There is no automatic advance, scroll capture, or required hover. The brief artwork entrance and progress transition are removed for reduced motion. The demo has its own keyboard-focusable scroll region.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.