---
title: "How to Create Sticky Scroll Sections in Framer"
description: "Build sticky scroll sections in Framer — the native sticky position option, a scroll-linked step-swap code pattern, and the bugs that break sticky."
canonical_url: "https://framerhub.io/blog/sticky-scroll-sections-framer"
last_updated: "2027-02-26T00:00:00.000Z"
---

Sticky scroll sections — where one element (often an image, diagram, or product screenshot) stays pinned in place while related text scrolls past beside it — are one of the most effective patterns for walking a visitor through a multi-step process or feature set without losing the visual context they're reading about. They're also one of the more commonly broken effects, usually for one specific, easy-to-miss reason. Here's how to build the pattern correctly.

## Native Framer: The Sticky Position Option

Framer supports `position: sticky` natively — select any Frame, open the layout/position settings, and choose **Sticky** instead of the default relative or absolute positioning. A sticky element stays fixed in place as its container scrolls, until the container's own bounds are reached, at which point it scrolls away normally with the rest of the content. This is a real, standards-based implementation, not a Framer-specific approximation, so it behaves exactly like CSS `position: sticky` does anywhere else on the web.

For a simple "this element stays visible while this other column scrolls" layout, native sticky positioning alone is often enough — place your pinned content (an image, a video, a diagram) in one column with sticky positioning applied, and your scrolling text content in an adjacent column with normal flow, and the basic effect works without any code.

## The #1 Reason Sticky Breaks: Overflow on an Ancestor

This is, by a wide margin, the most common reason a correctly-configured sticky element doesn't stick. `position: sticky` only works relative to its **nearest scrolling ancestor** — and any parent frame anywhere in the hierarchy above the sticky element with `overflow: hidden`, `overflow: scroll`, or `overflow: auto` breaks the sticky behavior entirely, because that ancestor becomes the scrolling context instead of the page (or the intended larger container), and the sticky element then only "sticks" relative to that smaller, often-invisible boundary.

If your sticky element isn't sticking, the fix is almost never in the sticky element's own settings — check every parent frame in the layer hierarchy above it for an overflow setting that shouldn't be there. This single issue accounts for the overwhelming majority of "sticky isn't working" reports, and it's easy to miss because the offending ancestor is often several levels up and set for an unrelated reason (clipping a decorative background, for instance).

A second, less common cause worth ruling out: a parent with a fixed `height` smaller than its sticky child's natural content plus scroll range can also prevent the sticky behavior from having room to actually engage — sticky positioning needs its containing block to be taller than the sticky element itself for there to be any scroll distance across which the "sticking" is visible at all. If the surrounding section's height is being constrained somewhere in the layer tree, that's worth checking right alongside overflow settings.

## Building the Full Pattern: Pinned Element + Scroll-Linked Content Swap

The version of this pattern seen most often on product and feature pages goes further than simple pinning — as the visitor scrolls through a series of text steps, the pinned image or diagram actually changes to match whichever step is currently in view. Here's a working implementation.

```jsx
import { useRef, useState, useEffect } from "react"
import { motion, useScroll } from "motion/react"

const STEPS = [
  { title: "Connect your data", image: "/step1.png" },
  { title: "Configure the filters", image: "/step2.png" },
  { title: "Publish and share", image: "/step3.png" },
]

export function StickyScrollFeature() {
  const containerRef = useRef(null)
  const [activeStep, setActiveStep] = useState(0)
  const { scrollYProgress } = useScroll({
    target: containerRef,
    offset: ["start start", "end end"],
  })

  useEffect(() => {
    return scrollYProgress.on("change", (progress) => {
      const index = Math.min(STEPS.length - 1, Math.floor(progress * STEPS.length))
      setActiveStep(index)
    })
  }, [scrollYProgress])

  return (
    <div ref={containerRef} style={{ display: "flex", gap: 48 }}>
      <div style={{ flex: 1, position: "sticky", top: 100, height: "fit-content" }}>
        <motion.img
          key={activeStep}
          src={STEPS[activeStep].image}
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={{ duration: 0.3 }}
          style={{ width: "100%", borderRadius: 16 }}
        />
      </div>
      <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 400 }}>
        {STEPS.map((step, i) => (
          <div
            key={i}
            style={{ opacity: activeStep === i ? 1 : 0.35, transition: "opacity 0.3s" }}
          >
            <h3>{step.title}</h3>
          </div>
        ))}
      </div>
    </div>
  )
}
```

The structure has three moving parts working together:

- **useScroll scoped to the whole section's container**, tracking scroll progress across the entire multi-step area, not just one step at a time.
- **scrollYProgress.on("change", ...) mapped to a step index**, dividing the total progress (0 to 1) evenly across however many steps you have, and updating `activeStep` whenever the visitor scrolls into a new step's range.
- **The right-hand column's steps use generous vertical gap (400px in the snippet)** — this is what determines how much scroll distance each step gets before advancing to the next. Too little gap and steps advance faster than a visitor can read them; too much and the section feels like it drags on longer than the content justifies.

## Tuning How Much Scroll Distance Each Step Gets

The `gap` value between text steps in the snippet above is the main lever controlling pacing — it directly determines how many pixels of scrolling correspond to each step's "active" window. A reasonable starting point is roughly 1.5-2x a typical viewport height per step, giving a visitor enough scroll distance to comfortably read the step's content before the pinned visual advances, without the section dragging on so long it feels stuck. Test with your actual content and actual step count rather than a fixed number — a step with a short one-line title needs less scroll distance than one with a full paragraph of supporting copy.

## Mobile: Rethink, Don't Just Shrink

The side-by-side pinned-image-plus-scrolling-text layout is fundamentally a wide-viewport pattern — on a phone-width screen, there typically isn't room for both columns to coexist meaningfully. Rather than trying to force the same layout to scale down, most production implementations switch to a simpler stacked pattern below a chosen breakpoint: either a standard vertical sequence of image-then-text-then-image-then-text blocks with no sticky positioning at all, or a lighter version where only the step titles are pinned in a smaller top bar while content scrolls beneath. Decide this explicitly rather than letting the desktop layout awkwardly compress into a viewport it wasn't designed for.

## Common Mistakes

- **An overflow-hidden ancestor breaking the sticky behavior**, as covered in detail above — always the first thing to check when sticky doesn't work.
- **Not giving the sticky element a defined top offset.** Without one, some implementations stick flush to the very top edge of the viewport, which can look cramped against a fixed navigation bar — set `top` to clear any fixed header height.
- **Scroll distance mismatched to content length.** A step with a lot of supporting text needs more scroll distance to stay comfortably readable than a short, single-line step — using identical spacing for every step regardless of content length produces an uneven pacing feel.
- **Forcing the desktop side-by-side layout onto mobile** instead of switching to a simpler stacked pattern, as covered above.

## Where This Pattern Works Best

- **Multi-step feature explanations** on a product page, where each step in a workflow pairs with a specific screenshot or diagram.
- **"How it works" sections**, walking a visitor through a process with the pinned visual updating as the explanation progresses.
- **Case study or portfolio narratives**, pairing a pinned project image with scrolling narrative text about the work.

It's a poor fit for content that doesn't have a natural step-by-step or paired structure — forcing unrelated content into this pattern just to use the effect produces an awkward, unmotivated pairing between the pinned and scrolling elements.

## Performance and Accessibility

The core sticky positioning itself is cheap — it's a native browser behavior, not a JavaScript-driven animation. The scroll-linked content-swap logic (the `useScroll` listener) is lightweight as well, since it's only updating a step index rather than animating continuous values every frame. For accessibility, make sure the underlying content is still logically ordered and readable if CSS or JavaScript fails to load — the text steps should exist in a sensible document order regardless of the pinned visual's state, so a screen reader or a visitor with `prefers-reduced-motion` set still gets a coherent, fully readable sequence even with the pinning and image-swap behavior disabled or simplified.

If you're combining this with other scroll-driven techniques on the same page, our [scroll animations roundup](/blog/scroll-animations-framer) covers nine other patterns worth knowing, and our [parallax scroll guide](/blog/parallax-scroll-framer) covers the closely related but distinct technique of layers moving at different speeds rather than staying pinned. FramerHub's [component library](/components), accessible through the [Components plugin](/plugins/framer-components), includes pre-built scroll and reveal components if assembling this pattern from scratch isn't the best use of time on a given project.

## FAQ

**Does Framer support position: sticky natively?**
Yes — Framer's layout panel includes a Sticky positioning option for any Frame, which behaves like standard CSS position: sticky, pinning the element within its scrollable container until that container's bounds are reached.

**Why isn't my sticky element sticking?**
The most common cause is an ancestor element with overflow set to hidden, scroll, or auto — position: sticky only works relative to its nearest scrolling ancestor, and an overflow-hidden parent anywhere in the chain breaks it. Check every parent frame's overflow setting, not just the sticky element's direct parent.

**How do I make content change while a sticky element stays pinned?**
Track scroll progress through the section with useScroll scoped to that section's container, then map ranges of that progress to which content (an image, a step number) should currently display — the sticky element itself doesn't move, but what's rendered inside it changes as the visitor scrolls past the pinned point.

**Do sticky scroll sections work well on mobile?**
The pinning behavior itself works on mobile, but the pattern often needs rethinking on narrow viewports — a side-by-side pinned-image-plus-scrolling-text layout usually doesn't fit a phone screen, so most implementations switch to a simpler stacked layout below a chosen breakpoint rather than trying to force the desktop pattern to scale down.

**How long should a sticky section's scroll-through take?**
Long enough to comfortably read through the content it's paired with, and no longer — a sticky section that stays pinned for an excessively long scroll distance starts to feel like the page is stuck rather than intentionally guiding the visitor through content.
