Velocity X Components by Aidxn Design

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.

tsx
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. 1. In Framer: Assets → Code → Create Code File.
  2. 2. Delete the placeholder and paste this entire file — it already carries its default export and addPropertyControls.