ehsan.blog
~/blog/how-to-build-a-horizontal-scroll-slideshow-in-react — zsh
cat how-to-build-a-horizontal-scroll-slideshow-in-react.md

How to build a full-page horizontal-scroll slideshow in React

·5 min read

Most websites scroll straight down. I wanted my portfolio to feel different: each section is a full-screen “slide”, and moving between them slides the whole page sideways. This is the core interaction of my site, and it turns out to be simpler than it looks — no library, just one CSS transform that I update on a ref.

In this post I’ll build the skeleton of that horizontal slideshow: a track of panels, and a goTo() function that moves it. Later posts add the controls that sit on top of this foundation: debounced wheel scrolling, keyboard navigation, and touch-swipe navigation. The whole build is written up as a narrative in building a CRT-terminal portfolio.

The mental model: one long strip

Instead of stacking sections vertically, we lay them out in a horizontal row — one long strip that is much wider than the screen. The screen is a fixed window, and we slide the strip left and right behind it so only one panel shows at a time.

flowchart LR
  subgraph Window["viewport (fixed window)"]
    P0["panel 0"]
  end
  P0 --- P1["panel 1"] --- P2["panel 2"] --- P3["panel 3"]

To show panel 2, we shift the whole strip left by two screen-widths. That’s the whole trick.

The layout

There are two elements: a wrapper that is exactly the size of the viewport and hides everything outside it, and a track inside it that lays the panels out in a row with flexbox. Each panel is exactly 100vw wide (vw = 1% of the viewport width) and never shrinks.

css
/* src/index.css */
.h-scroll-wrapper {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  overflow: hidden; /* clip everything outside the window */
}
.h-scroll-track {
  display: flex; /* lay panels out in a row */
  height: 100vh;
  will-change: transform; /* hint the browser we'll animate transform */
}
.h-scroll-panel {
  width: 100vw;
  height: 100vh;
  flex-shrink: 0; /* never squash panels to fit */
  position: relative;
}

overflow: hidden on the wrapper is what turns it into a window — the panels sticking out to the right are simply clipped. flex-shrink: 0 is easy to forget: without it, flexbox would squeeze all the panels to fit on one screen instead of letting them overflow.

The markup

In React it’s just the two nested divs, with the panels as children. I keep a ref on the track so I can move it directly:

tsx
// src/App.tsx
const trackRef = useRef<HTMLDivElement>(null)

return (
  <div className="h-scroll-wrapper">
    <div
      ref={trackRef}
      className="h-scroll-track"
      style={{ transform: "translateX(0)" }}
    >
      <HeroSection isActive={current === 0} />
      <AboutSection isActive={current === 1} />
      <ExperienceSection isActive={current === 2} />
      <SkillsSection isActive={current === 3} />
      <ProjectsSection isActive={current === 4} />
      <ContactSection isActive={current === 5} />
    </div>
  </div>
)

Each section reads an isActive prop (current === index) so it can start its entrance animations only when it’s the panel on screen.

Moving the track with goTo()

Here’s the heart of it. goTo(index) clamps the index so we can’t scroll past the ends, then sets the track’s transform to slide it left by index screen-widths:

tsx
// src/App.tsx
const SECTIONS = [/* hero, about, experience, skills, projects, contact */]
const totalSections = SECTIONS.length
const [current, setCurrent] = useState(0)
const [progress, setProgress] = useState(0)

const goTo = useCallback(
  (index: number) => {
    if (isMobile) return
    const clamped = Math.max(0, Math.min(totalSections - 1, index))
    setCurrent(clamped)
    setProgress((clamped / (totalSections - 1)) * 100)
    if (trackRef.current) {
      trackRef.current.style.transform = `translateX(-${clamped * 100}vw)`
      trackRef.current.style.transition =
        "transform 0.8s cubic-bezier(0.77, 0, 0.175, 1)"
    }
  },
  [isMobile, totalSections],
)

Math.max(0, Math.min(totalSections - 1, index)) keeps the index between 0 and the last panel — goTo(-1) stays on 0, goTo(99) stops on the last one. Then the key line: translateX(-${clamped * 100}vw). Panel 2 becomes translateX(-200vw), sliding the strip two screens to the left.

Why mutate the ref instead of using state?

You might expect to store the transform in state and let React render it. I deliberately don’t. The transform is written directly to the DOM node (trackRef.current.style.transform), bypassing React’s render cycle entirely.

Two reasons. First, this is a high-frequency visual animation — going through setState and a re-render on every move adds latency to something that should feel instant. Second, the browser’s own CSS transition is doing the actual animation, so React doesn’t need to know about the in-between frames at all.

The current/progress state still exists, but only for UI that needs to re-render — the active nav dot and the progress bar width. The movement itself is pure DOM. This split (imperative for the animation, declarative for the chrome) is the single most important idea in the whole component.

The easing

The feel comes from the transition:

plaintext
transform 0.8s cubic-bezier(0.77, 0, 0.175, 1)

cubic-bezier(0.77, 0, 0.175, 1) is an “ease-in-out” curve — it starts slow, speeds up in the middle, and eases out at the end, over 0.8 seconds. Because the transition lives on the transform property, every time we change translateX the browser animates smoothly between the old and new positions for free.

Takeaways

  • A horizontal slideshow is a fixed overflow: hidden window over a flex track of 100vw panels — slide the track with translateX.
  • flex-shrink: 0 on panels is required, or flexbox crams them onto one screen.
  • goTo(i) clamps the index and sets translateX(-i * 100vw); that one line is the whole navigation.
  • Write the transform straight to the ref, not to React state — let the CSS transition animate it, and keep state only for chrome that must re-render.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.