Custom Framer Component · Free, unlicensed
Numbered Process Steps
A four-step numbered process row explaining how your service works, with an optional highlighted final step and a disclaimer line.
processstepshow-it-works
This is a standalone Framer Code File — not installed with the CLI, not gated behind a subscription. Copy the whole file and paste it into Framer via Assets → Code → Create Code File.
Property controls
These are Framer's own addPropertyControls fields,
which show up as the right-hand properties panel once this is pasted into Framer. They are
not React props and have nothing to do with this site's licensed component API.
| Panel label | Control key | Control type |
|---|---|---|
| Steps | steps | Array |
| Disclaimer | note | String |
| Numbers | showNumbers | Boolean |
| Top padding | paddingTop | Number |
| Bottom padding | paddingBottom | Number |
| Background | background | Color |
| Card | card | Color |
| Ink | ink | Color |
| Accent | accent | Color |
Component source
The complete, unmodified file — copy the whole thing.
import { addPropertyControls, ControlType } from "framer"
import { useId } from "react"
export const META = {
slug: "numbered-process-steps",
title: "Numbered Process Steps",
description: "A four-step numbered process row explaining how your service works, with an optional highlighted final step and a disclaimer line.",
tags: ["process", "steps", "how-it-works"],
category: "custom-framer",
}
/**
* A four-step "how it works" section, numbered cards in a row.
*
* WIDTH IS LOCKED TO `fixed` — do not change it. Per Framer's docs `fixed` means the
* component "can fill 100% of its size", while `auto` means it "will dictate its own size
* based on its content". This is built on `width: 100%`, which only resolves under `fixed`.
* In `auto` the container shrink-wraps, 100% computes to 0, and the section collapses to a
* thin vertical strip. The root also carries a `min-width: 320px` floor as a second defence.
*
* Sizing is container-query driven, not viewport driven, so it responds to the Framer frame
* it sits in rather than the device. Four across, to two at 860px, to one at 460px.
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight any-prefer-fixed
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 560
*/
export default function NumberedProcessSteps(props) {
const {
steps = DEFAULT_STEPS,
note = DEFAULT_NOTE,
showNumbers = true,
background = "#F4F5F6",
card = "#2F3A48",
ink = "#2F3A48",
accent = "#F97316",
paddingTop = 96,
paddingBottom = 104,
style,
} = props
const scope = "fc-" + useId().replace(/[^a-zA-Z0-9]/g, "")
const list = Array.isArray(steps) ? steps : []
if (list.length === 0) return <Empty style={style} />
return (
<div className={scope} style={style}>
<style>{css(scope, { background, card, ink, accent, paddingTop, paddingBottom })}</style>
<section className="fc-process" aria-label="How it works">
<ol className="fc-process-steps">
{list.map((step, i) => (
<li
className={"fc-step" + (step.highlight ? " is-highlight" : "")}
key={i}
>
<div className="fc-step-head">
{showNumbers ? (
<span className="fc-step-n">{i + 1}</span>
) : (
<span />
)}
<StepArt name={step.art} />
</div>
<h3 className="fc-step-title">{step.title}</h3>
<p className="fc-step-copy">{step.copy}</p>
</li>
))}
</ol>
{note ? <p className="fc-process-note">{note}</p> : null}
</section>
</div>
)
}
/* The four steps, verbatim. The last one carries the highlight. */
const DEFAULT_STEPS = [
{
title: "Discover",
copy: "We get to know what you need and map out the shape of the work.",
art: "inspect",
},
{
title: "Plan",
copy: "We put together a clear plan, so you know exactly what's involved before anything starts.",
art: "submit",
},
{
title: "Build",
copy: "We get to work, keeping you in the loop at every stage.",
art: "negotiate",
},
{
title: "Deliver",
copy: "You get the finished result, checked and ready to go.",
art: "repair",
highlight: true,
},
]
const DEFAULT_NOTE = "*Terms and conditions apply."
function StepArt({ name }) {
return (
<svg
className="fc-step-art"
viewBox="0 0 38 38"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{name === "submit" ? (
<>
<rect x="9" y="7" width="20" height="24" rx="3" />
<path d="M14 15h10M14 20h10M14 25h6" />
</>
) : name === "negotiate" ? (
<>
<path d="M19 9v20" />
<path d="M9 14h20" />
<path d="M9 14 5 24h8zM29 14l-4 10h8z" />
<path d="M13 29h12" />
</>
) : name === "repair" ? (
<>
<path d="M4 24 19 12l15 12" />
<path d="M9 22v9h20v-9" />
<path d="m16 26 2.5 2.5L23 24" />
</>
) : (
<>
<path d="M4 26 18 14l14 12" />
<circle cx="26" cy="22" r="6" />
<path d="m30.5 26.5 4 4" />
</>
)}
</svg>
)
}
function Empty({ style }) {
return (
<div
style={{
...style,
display: "grid",
placeItems: "center",
minHeight: 120,
background: "#F4F5F6",
color: "#2F3A48",
font: "500 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
}}
>
Add at least one step in the properties panel.
</div>
)
}
function css(scope, { background, card, ink, accent, paddingTop, paddingBottom }) {
const s = `.${scope}`
const top = Math.max(0, Number(paddingTop) || 0)
const bottom = Math.max(0, Number(paddingBottom) || 0)
return `
/* A shrink-to-fit parent makes \`width: 100%\` compute to 0, which collapses every container
query to its narrowest step. The min-width is the floor; Framer overrides it on canvas. */
${s} { container: fc-process-shell / inline-size; width: 100%; min-width: 320px; }
${s} .fc-process {
--fc-content-max: 1356px;
--fc-gutter: 30px;
--fc-edge: max(var(--fc-gutter), calc((100% - var(--fc-content-max)) / 2 + var(--fc-gutter)));
container: fc-process / inline-size;
box-sizing: border-box;
width: 100%;
padding: ${top}px var(--fc-edge) ${bottom}px;
background: ${background};
color: ${ink};
font-stretch: 100%;
-webkit-font-smoothing: antialiased;
}
${s} .fc-process *, ${s} .fc-process *::before, ${s} .fc-process *::after { box-sizing: border-box; }
${s} .fc-process-steps {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: clamp(10px, 1.3cqi, 16px);
margin: 0;
padding: 0;
list-style: none;
}
${s} .fc-step {
position: relative;
display: flex;
flex-direction: column;
padding: clamp(18px, 2.2cqi, 26px);
border: 1px solid color-mix(in srgb, ${card} 92%, ${accent});
border-radius: 14px;
background: ${card};
color: #fff;
}
/* The highlighted step reads as the end of the run rather than as just another box. */
${s} .fc-step.is-highlight { border-color: color-mix(in srgb, ${accent} 48%, transparent); }
/* The connector reads left-to-right as a sequence; it stops before the last card. */
${s} .fc-step:not(:last-child)::after {
content: "";
position: absolute;
top: clamp(32px, 3.8cqi, 42px);
right: calc(clamp(10px, 1.3cqi, 16px) * -1);
width: clamp(10px, 1.3cqi, 16px);
height: 1px;
background: color-mix(in srgb, ${ink} 24%, transparent);
}
${s} .fc-step-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: clamp(14px, 1.8cqi, 20px);
}
${s} .fc-step-n {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 1px solid color-mix(in srgb, ${accent} 50%, transparent);
border-radius: 50%;
background: color-mix(in srgb, ${accent} 16%, transparent);
color: ${accent};
font-size: 13px;
font-weight: 600;
}
${s} .fc-step-art { width: clamp(30px, 3.4cqi, 38px); height: auto; color: ${accent}; opacity: .55; }
${s} .fc-step-title {
margin: 0;
font-size: clamp(15px, 1.65cqi, 19px);
font-weight: 600;
line-height: 1.25;
letter-spacing: -.02em;
}
${s} .fc-step-copy {
margin: clamp(7px, .9cqi, 11px) 0 0;
color: color-mix(in srgb, #fff 76%, ${card});
font-size: clamp(12.5px, 1.3cqi, 15px);
line-height: 1.55;
}
@container fc-process-shell (max-width: 767px) {
${s} .fc-process {
--fc-gutter: 15px;
padding-top: ${top * 0.5}px;
padding-bottom: ${bottom * 0.5}px;
}
}
${s} .fc-process-note {
margin: clamp(16px, 2cqi, 24px) auto 0;
padding-top: clamp(14px, 1.8cqi, 20px);
border-top: 1px solid color-mix(in srgb, ${ink} 14%, transparent);
max-width: 96ch;
color: color-mix(in srgb, ${ink} 60%, ${background});
font-size: clamp(10.5px, 1.05cqi, 12.5px);
line-height: 1.6;
text-align: center;
}
@container fc-process (max-width: 860px) {
${s} .fc-process-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); }
${s} .fc-step:nth-child(2)::after { display: none; }
}
@container fc-process (max-width: 460px) {
${s} .fc-process-steps { grid-template-columns: minmax(0, 1fr); }
${s} .fc-step::after { display: none !important; }
}
`
}
addPropertyControls(NumberedProcessSteps, {
steps: {
type: ControlType.Array,
title: "Steps",
control: {
type: ControlType.Object,
controls: {
title: { type: ControlType.String, title: "Step", defaultValue: "" },
copy: { type: ControlType.String, title: "Copy", displayTextArea: true, defaultValue: "" },
art: {
type: ControlType.Enum,
title: "Icon",
options: ["inspect", "submit", "negotiate", "repair"],
optionTitles: ["Search", "Document", "Discuss", "Build"],
defaultValue: "inspect",
},
highlight: {
type: ControlType.Boolean,
title: "Highlight step",
defaultValue: false,
description: "Outlines this step in the accent colour — use for the final/most important step.",
},
},
},
defaultValue: DEFAULT_STEPS,
},
note: {
type: ControlType.String,
title: "Disclaimer",
displayTextArea: true,
defaultValue: DEFAULT_NOTE,
},
showNumbers: { type: ControlType.Boolean, title: "Numbers", defaultValue: true },
paddingTop: {
type: ControlType.Number,
title: "Top padding",
defaultValue: 96,
min: 0,
max: 320,
step: 4,
unit: "px",
description: "Desktop value. Automatically halves on component widths below 768px.",
},
paddingBottom: {
type: ControlType.Number,
title: "Bottom padding",
defaultValue: 104,
min: 0,
max: 320,
step: 4,
unit: "px",
description: "Desktop value. Automatically halves on component widths below 768px.",
},
background: { type: ControlType.Color, title: "Background", defaultValue: "#F4F5F6" },
card: { type: ControlType.Color, title: "Card", defaultValue: "#2F3A48" },
ink: { type: ControlType.Color, title: "Ink", defaultValue: "#2F3A48" },
accent: {
type: ControlType.Color,
title: "Accent",
defaultValue: "#F97316",
},
})
- 1. In Framer: Assets → Code → Create Code File.
- 2. Delete the placeholder and paste this entire file — it already carries its default export and addPropertyControls.