Velocity X Components by Aidxn Design

Custom Framer Component · Free, unlicensed

Sticky Step Explainer

A sticky, scroll-scrubbed steps section where the image pins and the copy fills in word by word as the reader scrolls through each step.

stepsstickyscrollprocess

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
Heading heading String
Steps steps Array
Step 1 image step1Image ResponsiveImage
Step 2 image step2Image ResponsiveImage
Step 3 image step3Image ResponsiveImage
Step 4 image step4Image ResponsiveImage
Step 1 video step1Video File
Step 2 video step2Video File
Step 3 video step3Video File
Step 4 video step4Video File
Mask mask Enum
Layout layout Enum
Media side mediaSide Enum
Pin offset stickyTop Number
Background background Color
Ink ink Color
Accent accent Color

Component source

The complete, unmodified file — copy the whole thing.

tsx
import { addPropertyControls, ControlType } from "framer"
import { useEffect, useId, useMemo, useRef, useState } from "react"
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, type Transition } from "framer-motion"

export const META = {
    slug: "sticky-step-explainer",
    title: "Sticky Step Explainer",
    description: "A sticky, scroll-scrubbed steps section where the image pins and the copy fills in word by word as the reader scrolls through each step.",
    tags: ["steps", "sticky", "scroll", "process"],
    category: "custom-framer",
}

const WORD_START_OPACITY = 0.18
const WORD_SPREAD = 0.8
const WORD_DURATION = 0.2
/* THIS NUMBER IS A LAYOUT DECISION, NOT A ROUND ONE. Pick your own breakpoints the same way:
   a desktop floor where the two-column layout still has room to breathe, and the first size
   below it that stacks to one column. 992 is a common Desktop floor; move this only if your
   own breakpoints move. */
const STACKED_WIDTH = 992
const clamp01 = (value) => Math.min(1, Math.max(0, value))

function getWordOpacity(progress, index, count) {
    const start = count <= 1 ? 0 : (index / (count - 1)) * WORD_SPREAD
    const end = Math.min(1, start + WORD_DURATION)
    if (progress <= start) return WORD_START_OPACITY
    if (progress >= end) return 1

    const linear = (progress - start) / (end - start)
    const eased = linear * linear * (3 - 2 * linear)
    return WORD_START_OPACITY + (1 - WORD_START_OPACITY) * eased
}

function buildCopyModel(copy, accentPhrase) {
    const text = String(copy || "")
    const lowerText = text.toLocaleLowerCase()
    const phrases = String(accentPhrase || "")
        .split(",")
        .map((phrase) => phrase.trim())
        .filter(Boolean)
    const ranges = []

    phrases.forEach((phrase) => {
        const lowerPhrase = phrase.toLocaleLowerCase()
        let from = 0
        while (from < lowerText.length) {
            const start = lowerText.indexOf(lowerPhrase, from)
            if (start < 0) break
            ranges.push({ start, end: start + phrase.length })
            from = start + phrase.length
        }
    })
    ranges.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start))

    const runs = []
    let cursor = 0
    ranges.forEach((range) => {
        if (range.start < cursor) return
        if (range.start > cursor) runs.push({ text: text.slice(cursor, range.start), accent: false })
        runs.push({ text: text.slice(range.start, range.end), accent: true })
        cursor = range.end
    })
    if (cursor < text.length) runs.push({ text: text.slice(cursor), accent: false })
    if (runs.length === 0) runs.push({ text, accent: false })
    let wordIndex = 0
    const segments = runs
        .filter((run) => run.text)
        .map((run) => ({
            accent: run.accent,
            chunks: run.text
                .split(/(\s+)/)
                .filter(Boolean)
                .map((chunk) =>
                    /^\s+$/.test(chunk)
                        ? { text: chunk, wordIndex: null }
                        : { text: chunk, wordIndex: wordIndex++ }
                ),
        }))

    return { segments, wordCount: wordIndex }
}

/**
 * Sticky next-steps section. The image column pins, the copy fills in word by word as you
 * scroll, and the masked image swaps per step.
 *
 * Three parts carry it:
 *
 *   1. The image column is sticky. It pins for the whole section and swaps per step.
 *   2. The body copy fills in WORD BY WORD, scrubbed to scroll position — not a fade-in. That
 *      is the part that makes a reader actually read it, and it is the part worth protecting.
 *   3. The image panel is MASKED to a notch shape rather than a plain rounded rectangle.
 *
 * ONE PINNED STAGE AT EVERY WIDTH. Heading, picture and copy pin together and the section's
 * length comes from a runway of empty blocks below, one per step. Desktop runs the same thing,
 * two columns wide. Two reasons it is better than a tall-per-step column:
 *
 *   - THE HEADING STAYS ON SCREEN. A heading that scrolls off in the first step leaves the rest
 *     of the section answering a question that is no longer visible.
 *   - THE COPY HANDS OFF IN PLACE. Every step shares one grid cell, so the outgoing paragraph
 *     springs out of the exact spot the incoming one springs into, with the same transition
 *     the pictures use.
 *
 * `layout` is a READING-PACE control, not a structural one: both values pin, `full` holds each
 * step for 1.1 screens of scroll with larger type, `compact` for 0.85 with smaller. The runway
 * lengths are set in `measure()`.
 *
 * NO GSAP, AND IT DOES NOT NEED ANY. The whole effect is one scroll handler behind rAF plus a
 * clip-path. A scroll library is a real dependency in a Framer code component and this is a
 * handful of getBoundingClientRect calls per frame.
 *
 * TWO MASKS, BOTH SIMPLE SHAPES. `Notch` clips a soft corner off the frame — closer to a card
 * than a plain rectangle, with no artwork to ship. `None` is a plain rounded rectangle. Judge
 * them against your own photo before picking one; `notch` is the default.
 *
 * `position: sticky` DIES SILENTLY inside an ancestor with `overflow: hidden` — the column
 * just scrolls with the page and the effect does not happen, with no error to go on. If the
 * pinning stops working on the canvas, that is the first thing to check: a parent frame set
 * to clip content. `overflow: clip` is the version that does not break it.
 *
 * 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 strip.
 *
 * IMAGES ARE FRAMER IMAGE PROPS, never relative paths. A code component cannot resolve
 * `assets/…` and it breaks on publish.
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight any-prefer-fixed
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 2400
 * @framerDisableUnlink
 */
export default function StickyStepExplainer(props) {
    const {
        heading = "From “I’m not sure” to clear next steps.",
        steps = DEFAULT_STEPS,
        step1Image = null,
        step2Image = null,
        step3Image = null,
        step4Image = null,
        step1Video = null,
        step2Video = null,
        step3Video = null,
        step4Video = null,
        mask = "notch",
        layout = "full",
        mediaSide = "right",
        stickyTop = 96,
        background = "#F7FAFA",
        ink = "#2F3A48",
        accent = "#F97316",
        style,
    } = props

    const scope = "fc-" + useId().replace(/[^a-zA-Z0-9]/g, "")
    const list = Array.isArray(steps) ? steps : []
    const stepImages = [step1Image, step2Image, step3Image, step4Image]
    const stepVideos = [step1Video, step2Video, step3Video, step4Video]

    const rootRef = useRef<HTMLDivElement | null>(null)
    const runwayRefs = useRef<(HTMLDivElement | null)[]>([])
    const progressRefs = useRef<(HTMLSpanElement | null)[]>([])
    const stageRef = useRef<HTMLDivElement | null>(null)
    const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
    // Word `<span>` nodes per step, collected at render. Kept in a ref because the scroll
    // handler reads them every frame and must never be a reason to re-render.
    const wordRefs = useRef<(HTMLSpanElement | null)[][]>([])
    const wordProgressRef = useRef<number[]>([])

    const [active, setActive] = useState(0)
    const [isStacked, setIsStacked] = useState(false)
    const reduceMotion = useReducedMotion()
    const phase = useMotionValue(0)
    // Motion values keep the continuous scroll response outside React's render loop.
    const liftTarget = useTransform(phase, [0, 0.18, 0.76, 1], [12, 0, 0, -14])
    const lift = useSpring(liftTarget, { stiffness: 160, damping: 24, mass: 0.7 })
    const springTransition: Transition = reduceMotion
        ? { duration: 0 }
        : {
              type: "spring",
              stiffness: 175,
              damping: 21,
              mass: 0.85,
              opacity: { duration: 0.24, ease: "easeOut" },
          }

    // Pre-split every body copy into words and its optional accent run.
    const copySignature = list
        .map((step) => `${step?.copy || ""}${step?.accentPhrase ?? ""}`)
        .join("")
    const copyModels = useMemo(
        () => list.map((step) => buildCopyModel(step?.copy, step?.accentPhrase)),
        [copySignature]
    )

    useEffect(() => {
        const root = rootRef.current
        if (!root || list.length === 0) return
        let frame = 0
        let stacked = false
        let pinTop = 0
        let stageHeight = 0
        const scrollParents: HTMLElement[] = []
        let parent = root.parentElement
        while (parent) {
            if (/(auto|scroll|overlay)/.test(getComputedStyle(parent).overflowY)) {
                // The document scroller is represented by window, not its document-height box.
                if (parent !== document.body && parent !== document.documentElement) scrollParents.push(parent)
            }
            parent = parent.parentElement
        }
        const primaryScroller = scrollParents[0] || null
        const viewport = () => {
            const rect = primaryScroller?.getBoundingClientRect()
            return {
                top: rect ? rect.top + primaryScroller.clientTop : 0,
                height: primaryScroller?.clientHeight || window.innerHeight,
            }
        }
        const setVariable = (name, value) => {
            if (root.style.getPropertyValue(name) !== value) root.style.setProperty(name, value)
        }
        const measure = () => {
            const view = viewport()
            stacked = root.clientWidth < STACKED_WIDTH
            setIsStacked(stacked)
            const stage = stageRef.current
            const media = root.querySelector<HTMLElement>(".fc-step-frame")
            const head = root.querySelector<HTMLElement>(".fc-step-head")
            if (!stage || !media) return
            const desiredTop = Math.max(0, Number(stickyTop) || 0)
            // Reserve space for the actual heading and longest step, then spend what's left
            // on the picture. No fixed text heights and no text clipped inside the image.
            if (stacked) {
                const otherHeight = stage.offsetHeight - media.offsetHeight
                const budget = Math.max(96, view.height - desiredTop - otherHeight - 16)
                const mediaHeight = Math.min(root.clientWidth * 0.67, 340, view.height * 0.34, budget)
                setVariable("--fc-stage-media-height", `${Math.round(mediaHeight)}px`)
            } else {
                // Two columns, so only the pinned heading is above the picture — the copy sits
                // beside it. Budget off the heading alone and let the frame take the rest.
                const budget = Math.max(260, view.height - desiredTop - (head?.offsetHeight || 0) - 72)
                const mediaHeight = Math.min(budget, view.height * 0.62, 640)
                setVariable("--fc-stage-media-height", `${Math.round(mediaHeight)}px`)
            }
            stageHeight = stage.offsetHeight
            // Small landscape screens can need less header clearance to keep all copy visible.
            pinTop = Math.min(desiredTop, Math.max(0, view.height - stageHeight - 12))
            setVariable("--fc-stack-top", `${Math.round(pinTop)}px`)
            // One runway block per step is what the section actually scrolls through. `layout`
            // no longer changes the structure on desktop — both modes pin — so it buys reading
            // time instead: `full` holds each step for longer than a screen, `compact` less.
            const runway = stacked ? view.height : view.height * (layout === "full" ? 1.1 : 0.85)
            setVariable("--fc-scroll-height", `${Math.round(runway)}px`)
        }
        wordProgressRef.current = []

        const update = () => {
            const view = viewport()
            // Both modes pin, so both scrub against the runway. The fill line is the bottom
            // edge of the pinned stage: a runway block starts filling as it passes under it.
            const track = runwayRefs.current
            const fillStart = view.top + pinTop + stageHeight
            // Read every box before writing styles to avoid alternating layout reads/writes.
            const boxes = track.slice(0, list.length).map((el) => el?.getBoundingClientRect())
            let current = 0
            let currentProgress = 0
            boxes.forEach((rect, i) => {
                if (!rect || rect.height <= 0) return
                const raw = clamp01((fillStart - rect.top) / rect.height)
                // Leave a fully readable hold before the next picture springs in.
                const readable = clamp01(raw / (stacked ? 0.72 : 0.78))
                const progress = reduceMotion ? 1 : readable
                if (wordProgressRef.current[i] !== progress) {
                    const count = copyModels[i]?.wordCount || 0
                    const words = wordRefs.current[i] || []
                    for (let n = 0; n < count; n++) {
                        const word = words[n]
                        if (!word) continue
                        const opacity = getWordOpacity(progress, n, count)
                        word.style.opacity = opacity.toFixed(3)
                        word.classList.toggle("is-lit", opacity > WORD_START_OPACITY + 0.001)
                    }
                    wordProgressRef.current[i] = progress
                }
                if (progressRefs.current[i]) {
                    progressRefs.current[i].style.transform = `scaleX(${raw.toFixed(4)})`
                }
                if (rect.top <= fillStart + 0.5) {
                    current = i
                    currentProgress = raw
                }
            })
            phase.set(reduceMotion ? 0.5 : currentProgress)
            setActive(current)
        }
        /* CANCEL AND RESCHEDULE, rather than gating on a `queued` boolean that is only
           cleared inside the callback. The boolean version has a latch in it: if the frame it
           queues never runs, `queued` stays true and every later scroll is swallowed for the
           life of the page — the section freezes on whichever step it was showing. */
        const onScroll = () => {
            cancelAnimationFrame(frame)
            frame = requestAnimationFrame(update)
        }
        const onResize = () => { measure(); onScroll() }
        window.addEventListener("scroll", onScroll, { passive: true })
        window.addEventListener("resize", onResize)
        document.addEventListener("visibilitychange", onResize)
        scrollParents.forEach((node) => node.addEventListener("scroll", onScroll, { passive: true }))
        const observer = new ResizeObserver(onResize)
        observer.observe(root)
        if (stageRef.current) observer.observe(stageRef.current)
        if (primaryScroller) observer.observe(primaryScroller)
        measure()
        update()
        return () => {
            window.removeEventListener("scroll", onScroll)
            window.removeEventListener("resize", onResize)
            document.removeEventListener("visibilitychange", onResize)
            scrollParents.forEach((node) => node.removeEventListener("scroll", onScroll))
            observer.disconnect()
            cancelAnimationFrame(frame)
        }
    }, [list.length, copyModels, reduceMotion, layout, stickyTop, phase])

    useEffect(() => {
        videoRefs.current.forEach((video, i) => {
            if (!video) return
            if (i === active && !reduceMotion) video.play().catch(() => {})
            else video.pause()
        })
    }, [active, reduceMotion, step1Video, step2Video, step3Video, step4Video])

    if (list.length === 0) return <Empty style={style} />

    const shape = MASK_SHAPES[mask] ?? ""

    return (
        <div className={scope} style={style} ref={rootRef}>
            <style>{css(scope, { background, ink, accent, stickyTop, layout })}</style>
            <section
                className="fc-step"
                data-mask={mask}
                data-layout={layout}
                data-side={mediaSide}
                aria-labelledby={heading ? `${scope}-heading` : undefined}
            >
                <div className="fc-step-stage" ref={stageRef}>
                {/* The heading is INSIDE the grid, not above it. It shares the text column with
                    the steps so the two of them centre as one block against the picture beside
                    them — see the grid rules in `css()`. On one column it just runs first. */}
                <div className="fc-step-grid">
                <div className="fc-step-head">
                    {heading ? (
                        <h2 className="fc-step-heading" id={`${scope}-heading`}>
                            {heading}
                        </h2>
                    ) : null}
                    <div className="fc-step-progress" aria-hidden="true">
                        <span className="fc-step-position">{String(active + 1).padStart(2, "0")} / {String(list.length).padStart(2, "0")}</span>
                        <div className="fc-step-progress-tracks">
                            {list.map((_, i) => (
                                <span className="fc-step-progress-track" key={i}>
                                    <span ref={(el) => { progressRefs.current[i] = el }} />
                                </span>
                            ))}
                        </div>
                    </div>
                </div>

                    {/* Media first in the SOURCE order so that on mobile — where the grid
                        collapses to one column — the pinned panel sits above the copy it
                        illustrates. There is no second column to pin into down there. */}
                    <div className="fc-step-media">
                        <motion.div className="fc-step-frame" style={{ y: reduceMotion ? 0 : lift }}>
                            {/* Cards remain mounted after passing. Adjacent cards keep a dim
                                preview above/below while the active card rests in the centre. */}
                            {list.map((step, i) => {
                                const video = stepVideos[i]
                                const image = stepImages[i] || step.image
                                const offset = i - active
                                const distance = Math.abs(offset)
                                const panelState =
                                    offset === 0
                                        ? { y: "0%", opacity: 1, scale: 1, zIndex: 2 }
                                        : {
                                              y: reduceMotion ? "0%" : `${offset * 116}%`,
                                              opacity: isStacked || reduceMotion ? 0 : distance === 1 ? 0.2 : 0,
                                              scale: reduceMotion ? 1 : Math.max(0.88, 1 - distance * 0.055),
                                              zIndex: Math.max(0, 2 - distance),
                                          }
                                const imageState =
                                    reduceMotion ? { y: "0%", scale: 1 } : offset === 0
                                        ? { y: "0%", scale: 1.02 }
                                        : offset < 0
                                          ? { y: "8%", scale: 1.055 }
                                          : { y: "-10%", scale: 1.055 }

                                return (
                                    <motion.figure
                                        className={"fc-step-panel" + (i === active ? " is-active" : "")}
                                        key={i}
                                        initial={false}
                                        animate={panelState}
                                        transition={springTransition}
                                        style={{
                                            clipPath: video ? undefined : shape || undefined,
                                            pointerEvents: i === active ? "auto" : "none",
                                        }}
                                        aria-hidden={i === active ? undefined : "true"}
                                    >
                                        {video ? (
                                            <motion.video
                                                className="fc-step-el"
                                                src={video}
                                                ref={(el) => { videoRefs.current[i] = el }}
                                                autoPlay={i === active && !reduceMotion}
                                                poster={image?.src}
                                                preload="metadata"
                                                muted
                                                loop
                                                playsInline
                                                initial={false}
                                                animate={imageState}
                                                transition={springTransition}
                                            />
                                        ) : image?.src ? (
                                            <motion.img
                                                className="fc-step-el"
                                                src={image.src}
                                                srcSet={image.srcSet}
                                                alt={i === active ? image.alt || "" : ""}
                                                loading="eager"
                                                decoding="async"
                                                initial={false}
                                                animate={imageState}
                                                transition={springTransition}
                                            />
                                        ) : null}
                                        <span className="fc-step-count" aria-hidden="true">
                                            {String(i + 1).padStart(2, "0")}
                                        </span>
                                    </motion.figure>
                                )
                            })}
                        </motion.div>
                    </div>

                    <ol className="fc-step-list">
                        {list.map((step, i) => (
                            <li
                                className={
                                    "fc-step-item" +
                                    (step.highlight ? " is-highlight" : "") +
                                    (i === active ? " is-active" : "")
                                }
                                key={i}
                            >
                                <motion.div
                                    className="fc-step-item-inner"
                                    initial={false}
                                    animate={
                                        i === active
                                            ? { opacity: 1, y: 0, scale: 1 }
                                            : {
                                                  opacity: 0,
                                                  y: reduceMotion ? 0 : (i < active ? -30 : 38),
                                                  scale: reduceMotion ? 1 : 0.97,
                                              }
                                    }
                                    transition={reduceMotion ? { duration: 0 } : { ...springTransition, delay: i === active ? 0.07 : 0 }}
                                    aria-hidden={i !== active ? true : undefined}
                                >
                                    <span className="fc-step-n" aria-hidden="true">
                                        {String(i + 1).padStart(2, "0")}
                                    </span>
                                    <h3 className="fc-step-title">{step.title}</h3>
                                    <p className="fc-step-copy" aria-label={String(step.copy || "")}>
                                        {(copyModels[i]?.segments || []).map((segment, segmentIndex) => {
                                            const content = segment.chunks.map((chunk, chunkIndex) =>
                                                chunk.wordIndex == null ? (
                                                    chunk.text
                                                ) : (
                                                    <span
                                                        className="fc-w"
                                                        aria-hidden="true"
                                                        key={chunkIndex}
                                                        ref={(el) => {
                                                            if (!wordRefs.current[i]) wordRefs.current[i] = []
                                                            wordRefs.current[i][chunk.wordIndex] = el
                                                        }}
                                                    >
                                                        {chunk.text}
                                                    </span>
                                                )
                                            )

                                            return segment.accent ? (
                                                <strong className="fc-accent-pill" key={segmentIndex}>
                                                    {content}
                                                </strong>
                                            ) : (
                                                <span className="fc-copy-run" key={segmentIndex}>
                                                    {content}
                                                </span>
                                            )
                                        })}
                                    </p>
                                </motion.div>
                            </li>
                        ))}
                    </ol>
                </div>
                </div>
                <div className="fc-step-runway" aria-hidden="true">
                    {list.map((_, i) => <div key={i} ref={(el) => { runwayRefs.current[i] = el }} />)}
                </div>
            </section>
        </div>
    )
}

/* NOTCH IS A POLYGON, A PLAIN SHAPE WITH NO ARTWORK TO SHIP. It clips one soft corner off the
   frame so the panel reads as a deliberate card rather than a plain rectangle. `None` turns the
   mask off entirely and falls back to a rounded rectangle with a drop shadow. */
const MASK_SHAPES = {
    notch: "polygon(6% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 6%, 6% 6%)",
    none: "",
}

const DEFAULT_STEPS = [
    {
        title: "Book a consultation",
        copy: "Tell us what's going on and we'll find a time that works.",
        accentPhrase: "",
    },
    {
        title: "We assess the details",
        copy: "We look closely, document what we find, and explain it in plain English.",
        accentPhrase: "",
    },
    {
        title: "Get a clear plan forward",
        copy: "You'll know exactly what's involved and what happens next before you decide anything.",
        accentPhrase: "",
        highlight: true,
    },
]

function Empty({ style }) {
    return (
        <div
            style={{
                ...style,
                display: "grid",
                placeItems: "center",
                minHeight: 160,
                background: "#F4F5F6",
                color: "#2F3A48",
                font: "500 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
                textAlign: "center",
                padding: 20,
            }}
        >
            Add at least one step in the properties panel.
        </div>
    )
}

function css(scope, { background, ink, accent, stickyTop, layout }) {
    const s = `.${scope}`
    return `
/* Respect padded phone frames; a fixed 320px floor widens the whole document on small screens. */
${s} { container: fc-step-shell / inline-size; width: 100%; min-width: 0; max-width: 100%; overflow-x: clip; }
${s} .fc-step {
  --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-step / inline-size;
  box-sizing: border-box;
  width: 100%;
  padding: clamp(46px, 7cqi, 96px) var(--fc-edge) clamp(40px, 6cqi, 80px);
  background: ${background};
  color: ${ink};
  font-stretch: 100%;
  -webkit-font-smoothing: antialiased;
}
${s} .fc-step *, ${s} .fc-step *::before, ${s} .fc-step *::after { box-sizing: border-box; }

/* NO \`ch\` CAP ON THIS BLOCK. \`ch\` resolves against the element's OWN font-size — if it is set
   on a wrapper it resolves against the inherited 16px instead of the heading's own larger size,
   computing far narrower than intended and wrapping the heading to too many lines. The measure
   belongs on the heading itself, where it resolves against the heading's own size. */
/* THE PINNED STAGE — HEADING, PICTURE AND COPY PIN TOGETHER, AT EVERY WIDTH.
   One sticky stage; the section's length comes from the runway below it rather than from tall
   steps. The heading staying on screen is the point — the reader can always see which question
   is being answered — and it is also what lets the copy hand off IN PLACE with a spring instead
   of scrolling past as a column of dim ghosts.

   Desktop keeps its two columns inside that stage; only the narrow block below collapses it. */
${s} .fc-step-stage {
  position: sticky;
  top: var(--fc-stack-top, ${Math.max(0, stickyTop)}px);
  z-index: 1;
  display: block;
  background: ${background};
}
${s} .fc-step-runway { display: block; pointer-events: none; }
${s} .fc-step-runway > div { height: var(--fc-scroll-height, 100svh); }
${s} .fc-step-head { margin: 0; }
${s} .fc-step-eyebrow {
  display: block;
  margin-bottom: 10px;
  color: ${accent};
  font-size: clamp(11px, 1.1cqi, 13px);
  font-weight: 600;
  letter-spacing: .09em;
  text-transform: uppercase;
}
${s} .fc-step-heading {
  margin: 0;
  max-width: 18ch;
  font-size: clamp(24px, 3.1cqi, 38px);
  font-weight: 600;
  line-height: 1.08;
  letter-spacing: -.035em;
}

/* Media is ALWAYS FIRST IN THE SOURCE ORDER, whichever side it ends up on. On mobile the grid
   collapses to one column and the pinned panel has to sit ABOVE the copy it illustrates —
   there is no second column to pin into down there — and source order is what guarantees that
   no matter which side desktop is using. \`order\` moves it on desktop only. */
/* TWO ROWS, AND THE PICTURE SPANS BOTH. That is what vertically centres the text against it.
   The heading sits at the BOTTOM of row one and the step at the TOP of row two, so when the
   picture is taller than the two of them — which it almost always is — the surplus splits
   evenly above and below and the pair lands centred on the frame. Equivalent to centring a
   wrapper around them, without a wrapper that would then have to be undone on one column. */
${s} .fc-step-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
  grid-template-rows: auto auto;
  column-gap: clamp(24px, 4cqi, 72px);
  row-gap: clamp(18px, 2.4cqi, 36px);
}
/* Media right is the default — it puts the reading column on the left where a left-to-right
   reader starts. */
${s} .fc-step-head { grid-row: 1; align-self: end; }
${s} .fc-step-list { grid-row: 2; align-self: start; }
${s} .fc-step-media { grid-row: 1 / span 2; align-self: center; }
${s} .fc-step[data-side="right"] .fc-step-head,
${s} .fc-step[data-side="right"] .fc-step-list { grid-column: 1; }
${s} .fc-step[data-side="right"] .fc-step-media { grid-column: 2; }
${s} .fc-step[data-side="left"] .fc-step-grid { grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); }
${s} .fc-step[data-side="left"] .fc-step-head,
${s} .fc-step[data-side="left"] .fc-step-list { grid-column: 2; }
${s} .fc-step[data-side="left"] .fc-step-media { grid-column: 1; }

/* The media column no longer pins on its own — the whole stage around it does. It is a plain
   block again, and its height is measured in JS (\`--fc-stage-media-height\`) so the picture
   takes whatever the heading and the tallest paragraph leave on the screen in front of it.
   A frame taller than the viewport is a frame with its top cut off, which is what a
   \`max-height\` cap would otherwise guess at with a viewport percentage; the measured budget
   does that job at every width instead. */
${s} .fc-step-media { position: relative; top: auto; }
${s} .fc-step-frame {
  position: relative;
  width: 100%;
  height: var(--fc-stage-media-height, 52vh);
  aspect-ratio: auto;
  overflow: visible;
  background: transparent;
}
${s} .fc-step[data-mask="none"] .fc-step-panel,
${s} .fc-step[data-mask="notch"] .fc-step-panel {
  box-shadow: 0 18px 44px color-mix(in srgb, ${ink} 20%, transparent);
}

${s} .fc-step-panel {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  overflow: hidden;
  border-radius: 18px;
  background: ${ink};
  will-change: transform, opacity;
}
${s} .fc-step-el { display: block; width: 100%; height: 100%; object-fit: cover; }

${s} .fc-step-count {
  position: absolute;
  right: clamp(12px, 1.6cqi, 20px);
  bottom: clamp(10px, 1.4cqi, 18px);
  color: rgba(255, 255, 255, .82);
  font-size: clamp(11px, 1.1cqi, 13px);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: .08em;
  text-shadow: 0 1px 6px rgba(0, 0, 0, .5);
}

/* EVERY STEP SHARES ONE GRID CELL. The list is no longer a column you scroll down — it is a
   single slot that one step occupies at a time, so the outgoing paragraph springs out of the
   exact spot the incoming one springs into. The cell takes the height of the TALLEST step,
   which is why the stage never resizes mid-handoff. */
${s} .fc-step-list {
  position: relative;
  display: grid;
  margin: 0;
  padding: 0;
  list-style: none;
  isolation: isolate;
}
${s} .fc-step-item {
  grid-area: 1 / 1;
  position: relative;
  display: block;
  min-height: 0;
  padding: 0;
  pointer-events: none;
}
${s} .fc-step-item.is-active { z-index: 2; pointer-events: auto; }
${s} .fc-step-item-inner {
  position: relative;
  padding-left: 0;
  transform-origin: left top;
  will-change: transform, opacity;
}

/* In the flow, directly above the title. It used to be absolutely positioned into a left
   gutter, which only worked while a step box was exactly as tall as its own text. */
${s} .fc-step-n {
  position: static;
  display: block;
  margin: 0 0 clamp(10px, 1.2cqi, 16px);
  color: color-mix(in srgb, ${ink} 34%, ${background});
  font-size: clamp(10px, 1cqi, 12px);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: .1em;
  transition: color .35s ease;
}
${s} .fc-step-item.is-active .fc-step-n { color: ${accent}; }

${s} .fc-step-title {
  margin: 0 0 clamp(10px, 1.4cqi, 18px);
  font-size: clamp(30px, 5.2cqi, 56px);
  font-weight: 600;
  line-height: 1.12;
  letter-spacing: -.03em;
}
${s} .fc-step-copy {
  margin: 0;
  font-size: clamp(17px, 2cqi, 26px);
  font-weight: 500;
  line-height: 1.45;
  letter-spacing: -.02em;
}

/* THE WORD FILL. Overlapping progress windows let one word begin before the previous word has
   completely settled, producing a continuous reading sweep instead of discrete switches. */
${s} .fc-w {
  color: ${ink};
  opacity: ${WORD_START_OPACITY};
}
${s} .fc-accent-pill {
  position: relative;
  display: inline;
  margin-inline: .04em;
  padding: .04em .3em .1em;
  border: 1px solid transparent;
  border-radius: .4em;
  background: transparent;
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
  font-weight: 700;
  opacity: 1 !important;
  visibility: visible !important;
  transition: border-color .3s ease, background .3s ease;
}
${s} .fc-accent-pill:has(.fc-w.is-lit) {
  border-color: color-mix(in srgb, ${accent} 28%, transparent);
  background: color-mix(in srgb, ${accent} 22%, ${background});
  animation: ${scope}pill 420ms cubic-bezier(.22, 1, .36, 1) both;
}
@keyframes ${scope}pill { from { top: .14em; } to { top: 0; } }
/* The highlighted step carries the "is-highlight" class — its words land in the accent, so it
   reads as the end of the run rather than as just another paragraph. */
${s} .fc-step-item.is-highlight .fc-w.is-lit { color: color-mix(in srgb, ${accent} 82%, ${ink}); }

@container fc-step-shell (max-width: 767px) {
  ${s} .fc-step { --fc-gutter: 15px; }
}

/* THE PROGRESS RAIL RUNS AT EVERY WIDTH. A pinned stage has no other tell: the picture and one
   paragraph swap in place, so without the rail there is nothing on screen that says how far
   through the steps a reader is, or that there is another one coming. */
${s} .fc-step-progress {
  display: flex;
  align-items: center;
  gap: clamp(12px, 1.4cqi, 20px);
  margin-top: clamp(12px, 1.6cqi, 20px);
  max-width: 420px;
}
${s} .fc-step-position {
  flex: none;
  color: ${ink};
  font-size: clamp(10px, 1cqi, 12px);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: .1em;
}
${s} .fc-step-progress-tracks { display: flex; flex: 1; gap: 5px; }
${s} .fc-step-progress-track {
  height: 3px;
  flex: 1;
  border-radius: 3px;
  overflow: hidden;
  background: color-mix(in srgb, ${ink} 12%, transparent);
}
${s} .fc-step-progress-track > span {
  display: block;
  height: 100%;
  width: 100%;
  background: ${accent};
  transform: scaleX(0);
  transform-origin: left;
}

/* ---------------------------------------------------------------------------
   FULL vs COMPACT — A READING-PACE CONTROL, NOT TWO LAYOUTS.
   Both values pin. \`full\` keeps the big typography and holds each step for 1.1 screens of
   scroll; \`compact\` is smaller and quicker (0.85). The runway lengths are set in \`measure()\`,
   which is the only place the distinction lives.
   --------------------------------------------------------------------------- */
${s} .fc-step[data-layout="full"] .fc-step-n { font-size: clamp(11px, 1.15cqi, 14px); }
${s} .fc-step[data-layout="full"] .fc-step-title {
  margin-bottom: clamp(14px, 1.8cqi, 24px);
  font-size: clamp(30px, 4.4cqi, 50px);
}
/* The paragraph IS the section. At this size the word fill is legible across a room, which is
   the whole reason the effect earns its place. */
${s} .fc-step[data-layout="full"] .fc-step-copy {
  font-size: clamp(19px, 2.2cqi, 30px);
  line-height: 1.4;
}
/* NOTHING WRAPS THE FRAME ON DESKTOP — NO SECOND BOX, NO CLIPPING. The panels are supposed to
   run past the frame as they slide in and out — that IS the effect. A clip edge would cut the
   drop shadow off mid-blur and leave a visible seam. Leave it visible. See the one-column block
   for why clipping is still correct down there. */

/* Phones and tablets run the SAME pinned stage — the only difference down here is that the
   two columns collapse into one, so the picture sits above the copy it illustrates and
   everything shrinks to fit a single screen. The pin, the runway, the spring handoff and the
   progress rail are all in the shared rules above now, not duplicated here.

   The cutoff is \`STACKED_WIDTH - 0.02\`, in one expression, because this query and the
   \`stacked\` flag in \`measure()\` MUST agree. If CSS collapses the columns while JS still
   thinks it is on desktop, the media keeps a desktop-sized height budget inside a one-column
   stage and the copy falls off the bottom of the pin — with nothing in the console. */
@container fc-step-shell (max-width: ${STACKED_WIDTH - 0.02}px) {
  ${s} .fc-step { padding-top: 24px; padding-bottom: 24px; }
  ${s} .fc-step-stage { padding: 12px 0; }
  ${s} .fc-step-head { max-width: 620px; margin: 0 auto; }
  ${s} .fc-step-heading {
    max-width: 26ch;
    font-size: clamp(23px, 4.2cqi, 34px);
    line-height: 1.12;
    letter-spacing: -.035em;
  }
  ${s} .fc-step-progress { gap: 16px; margin-top: 14px; max-width: none; }
  ${s} .fc-step-position { font-size: 10px; }
  ${s} .fc-step[data-side] .fc-step-grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: none;
    gap: 18px;
    max-width: 620px;
    margin-inline: auto;
  }
  ${s} .fc-step[data-side] .fc-step-head,
  ${s} .fc-step[data-side] .fc-step-list,
  ${s} .fc-step[data-side] .fc-step-media {
    grid-column: 1;
    grid-row: auto;
    align-self: auto;
  }
  ${s} .fc-step[data-side] .fc-step-media {
    order: 0;
    display: block;
    min-height: 0;
    padding: 10px;
    /* The adjacent previews are hidden at this size (see \`isStacked\` in the component), so
       there is nothing to leave room for and a panel springing past the frame would only
       widen the page. \`clip\`, never \`hidden\` — \`hidden\` makes this a scroll container and
       the pin above it dies silently. */
    overflow: clip;
  }
  ${s} .fc-step .fc-step-frame,
  ${s} .fc-step[data-mask] .fc-step-frame {
    width: 100%;
    height: var(--fc-stage-media-height, 28svh);
    aspect-ratio: auto;
    margin: 0;
  }
  ${s} .fc-step-panel {
    border-radius: 14px;
    box-shadow: none;
  }
  ${s} .fc-step-count { display: none; }
  ${s} .fc-step[data-side] .fc-step-list { order: 1; }
  ${s} .fc-step-item-inner {
    padding: 0 2px;
    transform-origin: center top;
  }
  ${s} .fc-step .fc-step-n,
  ${s} .fc-step[data-layout="full"] .fc-step-n {
    margin: 0 0 8px;
    font-size: 10px;
    letter-spacing: .1em;
  }
  ${s} .fc-step .fc-step-title,
  ${s} .fc-step[data-layout="full"] .fc-step-title {
    margin: 0 0 12px;
    max-width: 24ch;
    font-size: clamp(25px, 4.7cqi, 36px);
    line-height: 1.1;
    letter-spacing: -.035em;
  }
  ${s} .fc-step .fc-step-copy,
  ${s} .fc-step[data-layout="full"] .fc-step-copy {
    max-width: 48ch;
    font-size: clamp(17px, 2.8cqi, 22px);
    line-height: 1.45;
    letter-spacing: -.02em;
  }
}

/* A pinned panel that animates while someone has asked for less motion is the worst version of
   this section. The fill still happens — it is the readable part — it just stops sliding. */
@media (prefers-reduced-motion: reduce) {
  ${s} .fc-step-panel { will-change: auto; }
  ${s} .fc-step-frame { transition: none; }
  ${s} .fc-accent-pill { transition: none; transform: none; animation: none !important; }
}
`
}

addPropertyControls(StickyStepExplainer, {
    heading: {
        type: ControlType.String,
        title: "Heading",
        displayTextArea: true,
        defaultValue: "From “I’m not sure” to clear next steps.",
    },
    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: "",
                    description: "Fills in word by word as it scrolls through the middle of the screen.",
                },
                accentPhrase: {
                    type: ControlType.String,
                    title: "Accent phrases",
                    defaultValue: "",
                    description: "Comma-separated words or phrases to show as individual bold accent pills.",
                },
                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,
    },
    step1Image: {
        type: ControlType.ResponsiveImage,
        title: "Step 1 image",
    },
    step2Image: {
        type: ControlType.ResponsiveImage,
        title: "Step 2 image",
    },
    step3Image: {
        type: ControlType.ResponsiveImage,
        title: "Step 3 image",
    },
    step4Image: {
        type: ControlType.ResponsiveImage,
        title: "Step 4 image",
    },
    step1Video: {
        type: ControlType.File,
        title: "Step 1 video",
        allowedFileTypes: ["mp4", "webm", "mov"],
        description: "Overrides the step 1 image when set. Not masked — plays as a plain rectangle.",
    },
    step2Video: {
        type: ControlType.File,
        title: "Step 2 video",
        allowedFileTypes: ["mp4", "webm", "mov"],
        description: "Overrides the step 2 image when set. Not masked — plays as a plain rectangle.",
    },
    step3Video: {
        type: ControlType.File,
        title: "Step 3 video",
        allowedFileTypes: ["mp4", "webm", "mov"],
        description: "Overrides the step 3 image when set. Not masked — plays as a plain rectangle.",
    },
    step4Video: {
        type: ControlType.File,
        title: "Step 4 video",
        allowedFileTypes: ["mp4", "webm", "mov"],
        description: "Overrides the step 4 image when set. Not masked — plays as a plain rectangle.",
    },
    mask: {
        type: ControlType.Enum,
        title: "Mask",
        options: ["notch", "none"],
        optionTitles: ["Notch", "None"],
        defaultValue: "notch",
        description: "Notch clips a soft corner off the frame; None is a plain rounded rectangle.",
    },
    layout: {
        type: ControlType.Enum,
        title: "Layout",
        options: ["full", "compact"],
        optionTitles: ["Full — one at a time", "Compact"],
        defaultValue: "full",
        description:
            "Reading pace, not layout — every screen size pins the heading, picture and text together now. Full holds each step for slightly longer than a screen of scroll and sets the copy larger; Compact is smaller and quicker through.",
    },
    mediaSide: {
        type: ControlType.Enum,
        title: "Media side",
        options: ["right", "left"],
        optionTitles: ["Right", "Left"],
        defaultValue: "right",
        description: "Large Desktop and Desktop only. Below the stacked breakpoint the two columns collapse and the picture always sits above the text, whichever side is chosen here.",
    },
    stickyTop: {
        type: ControlType.Number,
        title: "Pin offset",
        defaultValue: 96,
        min: 0,
        max: 240,
        step: 4,
        unit: "px",
        description: "Space below the site header. On phones and tablets the entire heading, image and text composition pins at this offset; short screens reduce it to fit.",
    },

    background: { type: ControlType.Color, title: "Background", defaultValue: "#F7FAFA" },
    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.