/lovable-issues

Fixing Anchor Link Scroll Issues in Lovable Single-Page Apps

Discover why Lovable anchor links misfire in SPAs and how to fix them. Follow best practices for smooth in-page scrolling.

Matt Graham, CEO of Rapid Developers

Book a call with an Expert

Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.

Book a free No-Code consultation

Why In-Page Anchor Links Don’t Scroll Correctly in Lovable

In Lovable previews and SPAs, in-page anchor links often don't land where you expect because client-side routing, fixed/overlay headers, layout shifts, and programmatic scroll behavior interfere with the browser’s native hash scrolling — the app either prevents the browser default or the page layout isn’t stable when the browser tries to scroll.

 

Why this happens (detailed)

 

Client-side routing and hash handling: Many Lovable apps are single-page apps (React/Next/etc.). The router or app code can intercept clicks or hashchange events and either prevent default scrolling or call navigation handlers that reset the scroll position.

  • Fixed/overlay UI: A top bar or fixed header can cover the target element. The browser scrolls the element into view, but the header sits on top, making it look like it didn’t reach the anchor.
  • Programmatic scrolls: Code that runs on route change (scrollTo, focus, or resetting scroll) can run after the browser’s native anchor scroll and move the page away from the target.
  • Layout instability and deferred content: Images, fonts, or async content that change layout after load can shift the target element’s final position. If the browser scrolls to the anchor before layout finishes, it ends up in the wrong spot.
  • CSS scroll behavior or smooth scrolling: scroll-behavior: smooth or JS smooth-scroll polyfills change timing; combined with other scripts this can produce unexpected final positions.
  • Hashhandled only by router: Some routers don’t map hash fragments to native anchor behavior and require explicit scroll restoration code — if that code is missing or runs too early/late, anchors don’t line up.

 

Lovable-friendly prompts (for repro and diagnosis — NOT fixes)

 

Paste the following into Lovable’s chat to make a reproducible page and add logging that shows what’s happening when anchors are clicked or when the hash changes. This helps identify which of the causes above is at play in your app.

 

// Prompt for Lovable: create a reproducible anchor diagnostic page and route
// Create file: src/pages/anchor-repro.tsx
// Add a route in src/App.tsx (or your router entry) that renders /anchor-repro
//
// The page includes:
// - a fixed header that simulates overlay
// - several anchor targets
// - large images or deferred content to cause layout shifts
// - JS listeners that log hashchange, click, and element offsets

Please create the following file and route edits.

Create file src/pages/anchor-repro.tsx with this content:
// Anchor repro page for diagnostics
import React, {useEffect, useRef} from 'react';

export default function AnchorRepro() {
  const log = (msg) => console.log('[anchor-repro]', msg);
  useEffect(() => {
    // Log initial hash and offsets on mount
    log('initial hash:' + location.hash);
    const report = () => {
      const targets = document.querySelectorAll('[data-anchor-target]');
      targets.forEach(t => {
        const rect = t.getBoundingClientRect();
        log(t.id + ' top=' + rect.top + ' docY=' + (window.scrollY + rect.top));
      });
    };
    // Log on load, resize, and after a short timeout to capture layout shifts
    report();
    window.addEventListener('hashchange', () => {
      log('hashchange -> ' + location.hash);
      report();
    });
    window.addEventListener('resize', report);
    const t = setTimeout(report, 800); // catch deferred layout changes
    return () => { clearTimeout(t); window.removeEventListener('resize', report); };
  }, []);
  return (
    <div>
      <div style={{position:'fixed', top:0, left:0, right:0, height:64, background:'#222', color:'#fff', zIndex:999}}>
        <div style={{padding:16}}>Fixed header (height 64px) - simulates overlay</div>
      </div>
      <div style={{height:80}}/> {/* spacer below fixed header */}
      <div style={{padding:20}}>
        <p>Use these links and watch the console logs for what the browser did.</p>
        <p>
          <a href="#section-a">Go to section A</a> •
          <a href="#section-b" style={{marginLeft:8}}>Go to section B</a> •
          <a href="#section-c" style={{marginLeft:8}}>Go to section C</a>
        </p>

        <div id="section-a" data-anchor-target style={{marginTop:400, padding:20, background:'#f5f5f5'}}>Section A (large gap above)</div>

        <img src="https://via.placeholder.com/1200x600" alt="big" style={{width:'100%', marginTop:40}} />

        <div id="section-b" data-anchor-target style={{marginTop:200, padding:20, background:'#eef'}}>Section B (after big image)</div>

        <div style={{height:400}}/>

        <div id="section-c" data-anchor-target style={{padding:20, background:'#efe'}}>Section C (bottom)</div>
      </div>
    </div>
  );
}

Then update src/App.tsx (or your router file) to add a route to "/anchor-repro" and link it from your nav so you can Preview the page in Lovable.

 

Use Lovable Preview to open /anchor-repro, click the links, and watch the browser console. The logs will tell you whether the hashchange happened before/after layout shifts, whether the fixed header covers the target, or whether some script is moving the scroll after the anchor jump.

 

Suggested follow-ups (copy these into Lovable to add notes)

 

// Prompt for Lovable: create docs/anchor-troubleshooting.md
// Create file docs/anchor-troubleshooting.md with a short checklist of likely causes and what the repro logging shows.
Please create docs/anchor-troubleshooting.md explaining:
// Observations to check in the console:
// - Did hashchange fire before layout settled?
// - Are element getBoundingClientRect() tops negative or covered by header?
// - Is there code that calls window.scrollTo() or focus() on route change?
// - Are large images or webfonts causing layout shifts after initial scroll?
// This doc should instruct developers to run the anchor repro and copy console output here.

 

Still stuck?
Copy this prompt into ChatGPT and get a clear, personalized explanation.

This prompt helps an AI assistant understand your setup and guide you through the fix step by step, without assuming technical knowledge.

AI AI Prompt

How to Fix Anchor Scrolling in Lovable Single-Page Apps

Add a tiny SPA-aware scroll helper and wire it into your app’s top-level entry so Lovable will scroll to in-page anchors after client-side navigation and when the hash changes. This handles fixed headers (offset), ensures smooth behavior, and works inside Lovable without any terminal steps.

 

Lovable prompt: implement anchor-scrolling helper (React + react-router)

 

Paste this into Lovable chat. It tells Lovable exactly what files to create/update. If your app does not use react-router, see the alternative prompt after this one.

  • Create src/lib/anchorScroll.ts
  • Update src/App.tsx (top-level router) to run the handler on location changes
  • Update src/index.css to add smooth scroll and a scroll-margin variable for fixed headers
// Please create src/lib/anchorScroll.ts with this content
// This exports scrollToHash(hash, offset) and initAnchorOnLoad(historyLocation)
// so top-level App can call it after navigation.
export function scrollToHash(hash, offset = 0) {
  if (!hash) return;
  const id = hash.startsWith('#') ? hash.slice(1) : hash;
  const el = document.getElementById(id);
  if (!el) return;
  const top = el.getBoundingClientRect().top + window.scrollY - offset;
  window.scrollTo({ top, behavior: 'smooth' });
}

export function handleHashAfterNav(locationHash, offset = 0) {
  // small delay lets routed content render
  setTimeout(() => scrollToHash(locationHash, offset), 50);
}
// Please update src/App.tsx (or wherever your top-level Router is).
// Add these imports near the top:
// import { useLocation } from 'react-router-dom';
// import { handleHashAfterNav } from './lib/anchorScroll';
//
// Inside your top-level App component, after you obtain location:
const location = useLocation();

// Replace or add a useEffect like this:
useEffect(() => {
  // adjust this offset to match your fixed header height (px)
  const HEADER_OFFSET = 72;
  handleHashAfterNav(location.hash, HEADER_OFFSET);
}, [location]);
// Please append to src/index.css (or global CSS)
:root {
  --header-offset: 72px; /* adjust to match HEADER_OFFSET in App.tsx */
}

html {
  scroll-behavior: smooth;
}

/* optional: allow using class="anchor-target" on headings for consistent offset */
.anchor-target {
  scroll-margin-top: var(--header-offset);
}

 

Lovable prompt: alternative for non-react-router SPAs

 

If you don't use react-router, paste this into Lovable to update your entry file (src/main.tsx or src/index.tsx) to listen for hash changes and run the same helper.

// Please create src/lib/anchorScroll.ts using the same content above (if not already created).
// Then update src/main.tsx or src/index.tsx: after app mounts, add:

// adjust this offset to match your fixed header height (px)
const HEADER_OFFSET = 72;

function onHashChange() {
  // import/require the handler dynamically if needed
  import('./lib/anchorScroll').then(m => m.handleHashAfterNav(window.location.hash, HEADER_OFFSET));
}

window.addEventListener('hashchange', onHashChange);
// also run once on initial load
document.addEventListener('DOMContentLoaded', () => onHashChange());

 

Notes & troubleshooting

 

  • If anchors still miss the spot, increase HEADER\_OFFSET to match header height (including sticky toolbars).
  • If content loads late (images or async data), increase the setTimeout delay in handleHashAfterNav from 50ms to ~200–400ms.
  • Preview the change in Lovable Preview; no terminal is needed. If you need deeper debugging or server build changes, use GitHub export from Lovable and edit locally.

Want to explore opportunities to work with us?

Connect with our team to unlock the full potential of no-code solutions with a no-commitment consultation!

Book a Free Consultation

Best Practices for Smooth Anchor Scrolling in Lovable SPAs

Use an explicit SPA-aware scroll handler that: waits for layout, computes and applies a fixed-header offset, performs smooth scrolling (via element.scrollIntoView or window.scrollTo), intercepts in-page anchor clicks to run the handler instead of default jump, and runs on route/hash change and initial load. Also move focus to the target for accessibility.

 

Lovable prompt — React (react-router) SPA: implement robust smooth anchor scrolling

 

Paste this into Lovable chat (Chat Mode). It will create/modify the files listed so your SPA scrolls smoothly to anchors, accounts for fixed headers, waits for layout, and focuses the target for accessibility. After applying, use Preview and Publish inside Lovable.

  • Create src/utils/scrollToHash.ts
  • Create src/components/AnchorLink.tsx
  • Update src/App.tsx (or your root router file) to call the handler on route/hash changes
// create src/utils/scrollToHash.ts
// utility to scroll to a hash with offset, smooth behavior, wait for layout, and focus target
export async function scrollToHash(hash: string | null) {
  if (!hash) return;
  // strip leading #
  const id = hash.startsWith('#') ? hash.slice(1) : hash;
  if (!id) return;

  // wait a frame and a small timeout to allow layout/rendering to finish
  await new Promise((resolve) => {
    requestAnimationFrame(() => setTimeout(resolve, 50));
  });

  const el = document.getElementById(id);
  if (!el) return;

  // compute offset for fixed header if present
  const header = document.querySelector('.site-header, [data-fixed-header]');
  const offset = header ? (header.getBoundingClientRect().height || 0) : 0;

  const top = window.scrollY + el.getBoundingClientRect().top - offset;

  window.scrollTo({
    top,
    behavior: 'smooth',
  });

  // ensure focus for accessibility
  const prevTabIndex = el.getAttribute('tabindex');
  if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1');
  el.focus({ preventScroll: true });
  if (prevTabIndex === null) el.removeAttribute('tabindex');
}
// create src/components/AnchorLink.tsx
// React component to wrap anchor links and use the SPA scroll helper
import React from 'react';
import { scrollToHash } from '../utils/scrollToHash';

export const AnchorLink: React.FC<React.PropsWithChildren<{ href: string }>> = ({ href, children }) => {
  const handleClick = (e: React.MouseEvent) => {
    // only intercept same-page hashes
    if (!href || !href.startsWith('#')) return;
    e.preventDefault();
    scrollToHash(href);
    // update URL hash without jumping
    history.replaceState(null, '', href);
  };

  return <a href={href} onClick={handleClick}>{children}</a>;
};
// update src/App.tsx (or your root router component)
// call scrollToHash on initial load and on location changes (react-router example)
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { scrollToHash } from './utils/scrollToHash';

export default function App() {
  const location = useLocation();

  useEffect(() => {
    // run on mount and whenever hash changes
    scrollToHash(location.hash || window.location.hash);
    // also listen for explicit hashchange (e.g., history.replace/others)
    const onHash = () => scrollToHash(window.location.hash);
    window.addEventListener('hashchange', onHash);
    return () => window.removeEventListener('hashchange', onHash);
  }, [location.pathname, location.search, location.hash]);

  return (
    // ... your routes/layout
  );
}

 

Lovable prompt — Vanilla JS single-page app

 

Paste this into Lovable chat to add a DOM-based solution. It will create src/scroll-to-hash.js and update index.html to include it.

  • Create src/scroll-to-hash.js and include it in index.html via <script src=...>
// create src/scroll-to-hash.js
// intercept clicks, handle initial load and hashchange, compute header offset, smooth scroll, and focus
(function () {
  // wait helper
  function waitForLayout(ms = 50) {
    return new Promise((res) => requestAnimationFrame(() => setTimeout(res, ms)));
  }

  async function scrollToHash(hash) {
    if (!hash) return;
    const id = hash.startsWith('#') ? hash.slice(1) : hash;
    if (!id) return;
    await waitForLayout();
    var el = document.getElementById(id);
    if (!el) return;
    var header = document.querySelector('.site-header, [data-fixed-header]');
    var offset = header ? (header.getBoundingClientRect().height || 0) : 0;
    var top = window.scrollY + el.getBoundingClientRect().top - offset;
    window.scrollTo({ top: top, behavior: 'smooth' });
    var hadTab = el.hasAttribute('tabindex');
    if (!hadTab) el.setAttribute('tabindex', '-1');
    el.focus({ preventScroll: true });
    if (!hadTab) el.removeAttribute('tabindex');
  }

  document.addEventListener('click', function (e) {
    var a = e.target.closest && e.target.closest('a[href^="#"]');
    if (!a) return;
    var href = a.getAttribute('href');
    if (!href || href === '#') return;
    e.preventDefault();
    history.replaceState(null, '', href);
    scrollToHash(href);
  });

  window.addEventListener('hashchange', function () {
    scrollToHash(window.location.hash);
  });

  // initial
  document.addEventListener('DOMContentLoaded', function () {
    scrollToHash(window.location.hash);
  });
})();