The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.
Read it first
Install kit: "Dotted process steps"
Library page: https://astro.baysixmedia.com/components/dotted-process-steps/
Files: 1 required, 1 supporting
Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/components/DottedProcessSteps.astro =====
---
/**
* DottedProcessSteps
*
* A light surface section: a centred orange eyebrow between two short lines,
* a big navy heading, and three steps in a row. Each step is a round pale
* medallion holding a line icon, hugged on its left by an orange half ring,
* with a small orange number badge on top; then a title and a short text.
* Dotted curved arrows join the steps (the first dips, the second arches).
*
* Appear on scroll: the title block and each step rise 40px and fade in,
* staggered; the number badges slide in from -20px; the connectors draw in
* left to right. The hidden start pose applies only after the script marks
* the root ready, so no-JS and reduced motion render at rest.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export type DottedProcessIcon = "globe-parcel" | "truck-plane" | "globe-truck" | "clipboard" | "warehouse";
export interface DottedProcessStep {
title: string;
text?: string;
/** Built-in line icon. Defaults cycle through the first three. */
icon?: DottedProcessIcon;
/** Badge text. Defaults to the 1-based index, zero padded ("01"). */
number?: string;
}
export interface DottedProcessStepsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** Level of each step title. Defaults to one below the heading. */
stepHeadingLevel?: "h3" | "h4";
steps?: DottedProcessStep[];
/** The dotted arrows between steps (desktop and tablet only). */
connectors?: boolean;
/** The section surface. */
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends DottedProcessStepsProps {}
/* No demo content: steps and heading are props. The generic eyebrow stays,
but it never makes the section render on its own. */
const {
eyebrow = "How It Works",
heading = "",
headingLevel = "h2",
stepHeadingLevel,
steps = [],
connectors = true,
background,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
const StepHeading = stepHeadingLevel ?? (headingLevel === "h2" ? "h3" : "h4");
const CYCLE: DottedProcessIcon[] = ["globe-parcel", "truck-plane", "globe-truck"];
/* A step with no title is dropped; with none left, nothing renders. */
const items = (steps ?? []).filter((step) => step?.title).map((step, i) => ({
...step,
icon: step.icon ?? CYCLE[i % CYCLE.length],
number: step.number ?? String(i + 1).padStart(2, "0"),
}));
/* Connectors sit on the column boundaries of a three-up row; with any other
count the geometry does not hold, so they are drawn only for three. */
const showConnectors = connectors && items.length === 3;
---
{items.length > 0 && (
<>{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section
class:list={["dps", className]}
id={id}
style={background ? `--dps-bg: ${background};` : undefined}
data-dps-root
>
<div class="dps-inner">
<div class="dps-title" data-dps-rise>
{
eyebrow && (
<p class="dps-eyebrow">
<span class="dps-eyebrow-line" aria-hidden="true" />
{eyebrow}
<span class="dps-eyebrow-line" aria-hidden="true" />
</p>
)
}
{heading && <Heading class="dps-heading">{heading}</Heading>}
</div>
<div class="dps-row">
<ol class="dps-steps" role="list">
{
items.map((step, i) => (
<li class="dps-step" data-dps-rise style={`--dps-i: ${i};`}>
<div class="dps-medal" aria-hidden="true">
<span class="dps-ring" />
<span class="dps-disc">
{step.icon === "globe-parcel" && (
<svg viewBox="0 0 60 60" width="60" height="60" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="24" cy="30" r="18" />
<ellipse cx="24" cy="30" rx="7.5" ry="18" />
<path d="M7.5 23h33M7.5 37h24" />
<rect x="33" y="31" width="19" height="16" rx="1.5" fill="#fff9f9" />
<path d="M33 36.5h19M42.5 31v5.5" />
<path d="M36 22h14m-3.5-3.5L50 22l-3.5 3.5" />
</svg>
)}
{step.icon === "truck-plane" && (
<svg viewBox="0 0 60 60" width="60" height="60" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<g transform="translate(38 17) rotate(-28)">
<path d="M-14 0H11" />
<path d="M2 0-6-10h-3.5L-6 0l-3.5 10H-6z" />
<path d="M-11 0-14-5h-2.5l1.5 5-1.5 5h2.5z" />
</g>
<rect x="7" y="31" width="29" height="16" rx="1.5" />
<path d="M16 31v16M26 31v16" />
<path d="M36 35h9l6 7v5H36z" />
<path d="M40 39h5" />
<circle cx="15" cy="49.5" r="3.5" fill="#fff9f9" />
<circle cx="44" cy="49.5" r="3.5" fill="#fff9f9" />
</svg>
)}
{step.icon === "globe-truck" && (
<svg viewBox="0 0 60 60" width="60" height="60" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6v9m-3.5-3.5L20 15l3.5-3.5" />
<circle cx="20" cy="33" r="14" />
<ellipse cx="20" cy="33" rx="6" ry="14" />
<path d="M6.5 28h27M6.5 38h20" />
<rect x="27" y="35" width="15" height="12" rx="1.5" fill="#fff9f9" />
<path d="M42 38h6l5 5v4H42z" fill="#fff9f9" />
<circle cx="32" cy="49" r="3" fill="#fff9f9" />
<circle cx="47" cy="49" r="3" fill="#fff9f9" />
<g transform="translate(45 16) rotate(-28) scale(0.8)">
<path d="M-14 0H11" />
<path d="M2 0-6-10h-3.5L-6 0l-3.5 10H-6z" />
<path d="M-11 0-14-5h-2.5l1.5 5-1.5 5h2.5z" />
</g>
</svg>
)}
{step.icon === "clipboard" && (
<svg viewBox="0 0 60 60" width="60" height="60" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="13" y="10" width="34" height="42" rx="3" />
<path d="M23 10V7h14v3M23 12h14" />
<path d="M20 24l3 3 5-6M32 25h9M20 36l3 3 5-6M32 37h9" />
</svg>
)}
{step.icon === "warehouse" && (
<svg viewBox="0 0 60 60" width="60" height="60" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 24 30 10l24 14v26H6z" />
<path d="M15 50V30h30v20M15 36h30M15 43h30" />
<rect x="21" y="44" width="8" height="6" />
<rect x="31" y="44" width="8" height="6" />
</svg>
)}
</span>
<span class="dps-num" data-dps-num>{step.number}</span>
</div>
<div class="dps-copy">
<StepHeading class="dps-step-title">
<span class="dps-sr">Step {i + 1}: </span>
{step.title}
</StepHeading>
{step.text && <p class="dps-step-text">{step.text}</p>}
</div>
</li>
))
}
</ol>
{
showConnectors && (
<div class="dps-links" aria-hidden="true">
<svg class="dps-link dps-link--1" viewBox="0 0 220 43" data-dps-link>
<path class="dps-dots" d="M3 7Q100 52 199 12" />
<path class="dps-head" d="M213.1 7 202.5 18.5 203.7 10.6 197.5 5.5Z" />
</svg>
<svg class="dps-link dps-link--2" viewBox="0 0 220 43" data-dps-link>
<path class="dps-dots" d="M3 30Q105-10 199 27.6" />
<path class="dps-head" d="M213 33.2 197.4 34.5 203.7 29.5 202.6 21.5Z" />
</svg>
</div>
)
}
</div>
</div>
</section></>
)}
<style>
.dps {
--dps-navy: var(--tg-navy, #020e28);
--dps-orange: var(--tg-orange, #fd5523);
--dps-muted: var(--tg-muted, #61697d);
--dps-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--dps-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--dps-ease: cubic-bezier(0.22, 1, 0.36, 1);
box-sizing: border-box;
padding: 80px 0;
background: var(--dps-bg, var(--tg-surface, #ecf0f1));
color: var(--dps-navy);
font-family: var(--dps-body);
overflow-x: clip;
}
.dps *,
.dps *::before,
.dps *::after {
box-sizing: border-box;
}
.dps-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 48px;
max-width: 1264px;
margin: 0 auto;
padding: 0 20px;
}
.dps-title {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
text-align: center;
}
.dps .dps-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
margin: 0;
font: 600 16px/1 var(--dps-display);
text-transform: uppercase;
color: var(--dps-orange);
}
.dps-eyebrow-line {
display: block;
width: 40px;
height: 1px;
background: currentColor;
}
.dps .dps-heading {
margin: 0;
font: 600 32px/1.2 var(--dps-display);
letter-spacing: normal;
color: var(--dps-navy);
overflow-wrap: break-word;
}
.dps-row {
position: relative;
width: 100%;
}
.dps .dps-steps {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 48px;
margin: 0;
padding: 0;
list-style: none;
}
.dps-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
min-width: 0;
text-align: center;
}
/* ---- medallion: 150 × 166, the measured box ---- */
.dps-medal {
position: relative;
flex: none;
width: 150px;
height: 166px;
}
.dps-ring {
position: absolute;
top: 16px;
left: 0;
width: 75px;
height: 149px;
border-radius: 75px 0 0 75px / 74.5px 0 0 74.5px;
background: var(--dps-orange);
}
.dps-disc {
position: absolute;
top: 26px;
left: 10px;
display: grid;
place-items: center;
width: 130px;
height: 130px;
border-radius: 50%;
background: #fff9f9;
color: var(--dps-navy);
}
.dps-disc svg {
display: block;
width: 60px;
height: 60px;
}
.dps-num {
position: absolute;
top: 0;
left: 50%;
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin-left: -20px;
border-radius: 50%;
background: var(--dps-orange);
font: 500 16px/26px var(--dps-display);
color: #fff;
}
.dps-copy {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
max-width: 329px;
}
.dps .dps-step-title {
margin: 0;
font: 600 24px/1.33 var(--dps-display);
color: var(--dps-navy);
}
.dps .dps-step-text {
margin: 0;
font: 400 16px/21.04px var(--dps-body);
color: var(--dps-muted);
}
.dps-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- connectors: hidden on a phone, where the steps stack ---- */
.dps-links {
display: none;
}
.dps-link {
position: absolute;
top: 83px;
width: clamp(96px, 15.3vw, 220px);
height: auto;
aspect-ratio: 220 / 43;
overflow: visible;
transform: translate(-50%, -50%);
clip-path: inset(0 0 0 0);
}
.dps-link--1 {
left: 32.1%;
}
.dps-link--2 {
left: 67.9%;
}
.dps-dots {
fill: none;
stroke: var(--dps-navy);
stroke-width: 2.6;
stroke-linecap: round;
stroke-dasharray: 0.1 6.4;
}
.dps-head {
fill: var(--dps-navy);
}
/* ---- appear on scroll ---- */
.dps-title,
.dps-step {
transition:
opacity 0.8s var(--dps-ease),
transform 0.8s var(--dps-ease);
}
.dps-step {
transition-delay: calc(0.15s + var(--dps-i, 0) * 0.1s);
}
.dps-num {
transition: transform 0.8s var(--dps-ease) calc(0.35s + var(--dps-i, 0) * 0.1s);
}
.dps-link {
transition: clip-path 1s var(--dps-ease) 0.6s;
}
.dps-link--2 {
transition-delay: 0.8s;
}
.dps[data-dps-ready]:not([data-in]) .dps-title,
.dps[data-dps-ready]:not([data-in]) .dps-step {
opacity: 0;
transform: translateY(40px);
}
.dps[data-dps-ready]:not([data-in]) .dps-num {
transform: translateX(-20px);
}
.dps[data-dps-ready]:not([data-in]) .dps-link {
clip-path: inset(0 100% 0 0);
}
/* ---- tablet and up: the three-up row with connectors ---- */
@media (min-width: 810px) {
.dps {
padding: 100px 0;
}
.dps-inner {
gap: 60px;
}
.dps .dps-heading {
font-size: 40px;
}
.dps .dps-steps {
grid-template-columns: repeat(3, minmax(0, 329px));
justify-content: space-between;
column-gap: 24px;
}
.dps-links {
display: block;
}
.dps-step {
gap: 40px;
}
.dps-copy {
gap: 20px;
}
}
@media (min-width: 1200px) {
.dps {
padding: 120px 0;
}
.dps .dps-eyebrow {
font-size: 18px;
}
.dps .dps-heading {
font-size: 48px;
line-height: 57.6px;
}
.dps .dps-step-title {
font-size: 30px;
line-height: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.dps-title,
.dps-step,
.dps-num,
.dps-link {
transition: none;
}
.dps[data-dps-ready]:not([data-in]) .dps-title,
.dps[data-dps-ready]:not([data-in]) .dps-step {
opacity: 1;
transform: none;
}
.dps[data-dps-ready]:not([data-in]) .dps-num {
transform: none;
}
.dps[data-dps-ready]:not([data-in]) .dps-link {
clip-path: inset(0 0 0 0);
}
}
@media (forced-colors: active) {
.dps-ring,
.dps-num {
forced-color-adjust: none;
}
.dps-disc {
border: 1px solid CanvasText;
}
}
</style>
<script>
/*
* Appear-on-scroll. One IntersectionObserver per instance, scoped to its
* root, torn down on astro:before-swap. It sets `data-in` once and stops
* watching. Under reduced motion it marks the root in straight away.
*/
function initDottedProcessSteps(root: HTMLElement) {
if (root.dataset.dpsReady === "" || root.dataset.in === "") return;
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced || !("IntersectionObserver" in window)) {
root.dataset.in = "";
return;
}
root.dataset.dpsReady = "";
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.dataset.in = "";
observer.disconnect();
}
},
{ threshold: 0.15 },
);
observer.observe(root);
signal.addEventListener("abort", () => observer.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.dpsReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-dps-root]").forEach(initDottedProcessSteps);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/DottedProcessSteps.astro =====
===== FILE (supporting): src/components/DottedProcessSteps.md =====
# Dotted process steps
A light grey band: a centred orange eyebrow between two short lines, a big
navy heading, and three steps joined by dotted curved arrows. Each step is a
pale round medallion holding a line icon, hugged on its left by an orange half
ring, with a small orange number badge on top, then a title and a line of
text.
Catalog page: `/components/dotted-process-steps`
Part of the Northpass Logistics family (third family, `--tg-*` tokens). Used
unchanged on the home and about pages.
## Files required
**`DottedProcessSteps.astro`**: one file, the whole component. No imports, no
packages, no image files. The icons are drawn inline.
`DottedProcessSteps.md`: this file. Documentation, not required to run.
## Basic usage
```astro
---
import DottedProcessSteps from "../components/DottedProcessSteps.astro";
---
<DottedProcessSteps
eyebrow="How It Works"
heading="Three Steps From Quote to Delivery"
steps={[
{ title: "Request a Rate", text: "Tell us what is moving.", icon: "globe-parcel" },
{ title: "Lock In Capacity", text: "We reserve the slot.", icon: "truck-plane" },
{ title: "Track to the Dock", text: "Follow every leg live.", icon: "globe-truck" },
]}
/>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"How It Works"` | Orange label with a line either side. `""` drops it. |
| `heading` | `string?` | `""` | The section heading. Empty drops it. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `stepHeadingLevel` | `"h3" \| "h4"?` | one below `headingLevel` | Level of each step title. |
| `steps` | `DottedProcessStep[]?` | `[]` | `{ title, text?, icon?, number? }`. A step with no `title` is dropped. |
| `connectors` | `boolean?` | `true` | The dotted arrows. Drawn only when there are exactly three steps. |
| `background` | `string?` | `--tg-surface` / `#ecf0f1` | Section surface. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` to skip loading fonts. |
| `class`, `id` | `string?` | | On the root `<section>`. |
`icon` is one of `"globe-parcel"`, `"truck-plane"`, `"globe-truck"`,
`"clipboard"`, `"warehouse"`; unset steps cycle through the first three.
`number` defaults to `"01"`, `"02"`, ….
No demo content ships as a default: the catalog preview passes its steps and
heading as props. With no steps the component renders nothing (not even its
font link) — the generic eyebrow alone never makes it render.
## Layout
- **Desktop (≥ 1200px):** 120px vertical padding, 1224px column, heading
48/57.6, three 329px steps spread `space-between`, step titles 30/40.
- **Tablet (810–1199px):** same three-up row, heading 40px, connectors scale
down (`clamp(96px, 15.3vw, 220px)`).
- **Phone (< 810px):** steps stack centred, 48px apart; connectors hidden;
heading 32px, step titles 24px.
The medallion is the measured 150 × 166 box: the orange half disc (75 × 149)
sits behind a 130px `#fff9f9` disc, leaving a ~9px crescent; the 40px badge
is centred on its top edge.
## Motion
Appear on scroll, once, when 15% of the section is in view:
| Element | From | Timing |
| :--- | :--- | :--- |
| Title block | `opacity 0`, `translateY(40px)` | 0.8s |
| Each step | `opacity 0`, `translateY(40px)` | 0.8s, staggered 0.1s from 0.15s |
| Number badges | `translateX(-20px)` | 0.8s, from 0.35s, staggered |
| Connectors | clipped to nothing, revealed left to right | 1s, from 0.6s / 0.8s |
No hover states: none were measured on this section.
The hidden start pose applies only once the script has set
`data-dps-ready` on the root, so with no JavaScript, or under
`prefers-reduced-motion: reduce`, the section renders at rest.
## Accessibility
- Steps are an `<ol>`; each title has a visually hidden "Step n:" prefix.
- The medallion, badge and connectors are `aria-hidden`.
- Nothing is focusable.
- In forced colours the disc gets an outline and the orange shapes keep their
colour.
## Script
One `IntersectionObserver` per instance, scoped to its root. Initialises on
load and on `astro:page-load`, is idempotent, and aborts its
`AbortController` on `astro:before-swap`. It never scrolls or moves focus.
## Approximations
- **The badge slide.** The brief lists the step numbers as starting at
`translateX(-20px)`. In the dump that transform sits on a 40px-wide badge,
which is also what Framer's `translateX(-50%)` centring looks like, so it
may be the centring rather than an appear offset. Implemented as the brief
says: the badge is centred with margins and slides in from −20px.
- **Connector draw-in** uses a `clip-path` reveal of the whole dotted arrow
rather than a `stroke-dashoffset`, because the stroke is already a dash
pattern (the dots). It reads as the arrow being drawn left to right.
- **Icons and connector curves** are original drawings in the reference's
style, not traced.
- The phone and tablet type sizes are estimated from the 390px screenshot;
there was no tablet capture.
===== END src/components/DottedProcessSteps.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.
--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.