You scroll halfway down a page. Click a link. Hit back. You're at the top again.
If this is happening in your Rails app, I can almost guarantee the problem is in your CSS—specifically, overflow: hidden on your <body> element.
Let me show you why this breaks Turbo and how to fix it.
The One Thing Turbo Tracks
Turbo Drive saves your scroll position before navigating away. When you hit back, it restores that position. Simple enough.
Here's the catch: Turbo uses window.scrollY to get that position. This value only tracks scrolling on the <body> element. If your body doesn't scroll—if scrolling happens inside some other container—window.scrollY is always 0.
Turbo saves 0. Turbo restores 0. You're back at the top.
The Layout That Breaks Everything
This CSS pattern is everywhere, and it completely breaks scroll preservation:
html,
body {
@apply h-full overflow-hidden;
}
.app-container {
@apply h-full overflow-y-auto;
}
Your body is locked. The .app-container scrolls instead. Turbo has no idea any scrolling happened.
You'll see this pattern in layouts with fixed headers and footers:
<body class="h-full overflow-hidden">
<header class="fixed top-0 w-full h-16 bg-white border-b z-50">
<%= render "shared/nav" %>
</header>
<main class="h-full overflow-y-auto pt-16 pb-16">
<%= yield %>
</main>
<footer class="fixed bottom-0 w-full h-16 bg-white border-t">
<%= render "shared/footer" %>
</footer>
</body>
The header and footer stay put. Content scrolls between them. Users love it. Turbo doesn't.
The Fix: Let the Body Scroll
The solution is to restructure your layout so the <body> handles scrolling. You can still have sticky headers and footers—just use sticky instead of fixed.
Here's what that looks like:
<body class="min-h-screen flex flex-col">
<header class="sticky top-0 bg-white border-b z-50">
<%= render "shared/nav" %>
</header>
<main class="flex-1">
<%= yield %>
</main>
<footer class="border-t bg-white">
<%= render "shared/footer" %>
</footer>
</body>
That's it. No overflow-hidden on the body. The header sticks to the top as you scroll. The footer sits at the bottom of your content (or use sticky bottom-0 if you want it always visible).
Turbo now tracks your scroll position correctly.
Sticky vs Fixed: The Key Difference
position: fixed removes an element from the document flow. It's positioned relative to the viewport, and the rest of your content ignores it. This often leads to the overflow-hidden pattern because you need to manually manage spacing.
position: sticky keeps the element in the document flow until you scroll past its threshold, then it "sticks." The body still scrolls naturally.
<%# Fixed: requires overflow management %>
<header class="fixed top-0 inset-x-0 h-16">...</header>
<main class="pt-16">...</main> <%# Need padding to prevent overlap %>
<%# Sticky: just works %>
<header class="sticky top-0">...</header>
<main>...</main> <%# No padding needed %>
Sticky is almost always what you want for headers and navigation.
A Complete Turbo-Friendly Layout
Here's a full layout template that works with Turbo out of the box:
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html class="h-full">
<head>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body class="min-h-full flex flex-col bg-gray-50 text-gray-900">
<%# Sticky header - stays at top while scrolling %>
<header class="sticky top-0 z-50 bg-white border-b border-gray-200">
<nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<%= link_to "Home", root_path, class: "font-bold text-xl" %>
<div class="flex gap-6">
<%= link_to "Posts", posts_path %>
<%= link_to "About", about_path %>
</div>
</nav>
</header>
<%# Main content - grows to fill space %>
<main class="flex-1">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<%= yield %>
</div>
</main>
<%# Footer - always at bottom, scrolls with content %>
<footer class="bg-gray-900 text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<p>© <%= Date.current.year %> Your App</p>
</div>
</footer>
</body>
</html>
The body scrolls. The header sticks. The footer stays at the bottom. Back button works.
What If You Need Container Scrolling?
Sometimes you genuinely need a scrollable container—a chat window, a sidebar with a long list, a modal with lots of content. In those cases, you'll need to handle scroll preservation yourself.
Here's a Stimulus controller that does it:
// app/javascript/controllers/scroll_position_controller.js
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static values = { key: String }
connect() {
this.restore()
document.addEventListener('turbo:before-cache', this.save)
}
disconnect() {
document.removeEventListener('turbo:before-cache', this.save)
}
save = () => {
const key = this.keyValue || this.element.id
sessionStorage.setItem(`scroll-${key}`, this.element.scrollTop)
}
restore = () => {
const key = this.keyValue || this.element.id
const position = sessionStorage.getItem(`scroll-${key}`)
if (position) {
this.element.scrollTop = parseInt(position, 10)
}
}
}
Use it on any scrollable container:
<aside
class="w-64 h-screen overflow-y-auto border-r"
data-controller="scroll-position"
data-scroll-position-key-value="sidebar"
>
<%= render @items %>
</aside>
The controller saves the scroll position before Turbo caches the page, then restores it when the element reconnects.
Quick Checklist
Before you ship, test the back button:
- Navigate to a page with content that scrolls
- Scroll down
- Click a link
- Hit back
- Are you where you were?
If not, check these things:
- Search for
overflow-hiddenon body or html — This is the usual culprit - Replace
fixedwithsticky— Most fixed headers should be sticky - Remove height constraints on body — Use
min-h-screeninstead ofh-screen
# Find overflow rules in your stylesheets
grep -r "overflow" app/assets/stylesheets/
The Bottom Line
Turbo tracks window.scrollY. That value only changes when the <body> scrolls. If you lock the body with overflow-hidden and scroll a container instead, Turbo can't help you.
The fix is usually simple: let the body scroll and use sticky positioning for fixed-feeling elements. Your users will thank you when the back button actually works.
