The URL, structure and scroll position
intermediate·8 min·Markdown for agents
What this lesson teaches
Keep what the user sees in the URL so links and the back button work, arrange screens the way people think, and stop lists jumping when content loads.
Pick a tab, filter to "Assigned to me", change the sort. Then press "Open link in a new tab". Try it in both modes.
- Toast hidden under modal
The link, opened in a new tab
- Login button misaligned on Safari
- Dark logo on dark header
- Invoice PDF shows wrong total
When the view lived in component state, the new tab forgot everything. When it lived in the URL, the link was the view: same tab, same filter, same sort. And Back stepped through your changes.
The URL is the source of truth
The rule from Vercel's interface guidelines: anything a user can see right now should be rebuildable from the URL. Paste it in a new tab and you land on the same view. That's .
| State | Where | Example |
|---|---|---|
| The page | path | /projects/acme/issues |
| Tab | ?tab=closed | |
| Filters | query | ?status=open,blocked&assignee=me |
| Sort | query | ?sort=-updated_at |
| Pagination | query | ?cursor=eyJpZCI6... |
| Open panel or modal | query | ?modal=invite |
| Search | query | ?q=auth+bug |
What does not go in the URL: form drafts (local storage), login state (cookies), whether a toast is showing, hover and focus.
Three rules make it work:
- Deep-link every modal.
?modal=invite-teamshould open the invite dialog straight from the link. That's a . - One source. Read state from the URL (
useSearchParamsin Next.js) and write through the router. Copying URL state intouseStatecreates two versions that drift. - Back restores scroll. Normal links and most routers do this; a custom scroll lock or an intercepted click often breaks it. Check yours.
Structure the way people think
decides what people can find before any pixel is styled. The rule under all of it: arrange and name things the way users think about them, not the way the code or the org chart is split.
- Long settings get sections. Past about a dozen options, split them under a side nav with named groups (Account, Billing, Notifications), each a short page.
- : show what most people need; put rare options behind "Advanced", or reveal a feature when it becomes relevant. Benji Taylor describes how Family kept the basics at hand and revealed the rest by context.
- Name by the user's words. "Send messages in the background", not "Enable async relay mode".
- Tabs are views of one thing (this week / month / year), not unrelated destinations.
- Two levels of menu, then stop. Deeper than that, use pages with breadcrumbs and a URL.
- Search logs are rename requests. If people keep searching "invoices" and the nav says "Billing", rename it.
✕ Dead end
"Something went wrong." and a Back button.
✓ A way forward
"That link expired. Request a new one." with the button that does it.
Renaming beats restructuring. Before redrawing the sitemap, check whether the labels are the problem; a label is a one-line fix.
Keep the reader's place
One more way a page loses people: you're reading a chat log, older messages load above, and the message you were on jumps away. Try it with anchoring off, then on.
Each load shoves your message down and out of view.
means tying the view to a row, not to a pixel offset. Cheng Lou's pattern:
Pick an anchor when the user scrolls
The first row whose bottom is below the top of the view. Record how far it is from the top of the view.
When content changes, restore that distance
After the new rows render, set the scroll position so the anchor row sits exactly where it was.
Detect user scrolling by value
Compare the scroll position with the one you stored. Don't trust "a scroll event fired": your own scroll fires one too.
Store what the browser actually set
Read the scroll position back after setting it, because browsers round. Storing the value you asked for makes the next frame think the user scrolled.
If you anchor in code, set overflow-anchor: none on that scroller so the browser's built-in anchoring doesn't move it a second time. For a simple page, the browser's own overflow-anchor is the cheaper option. A chat that was showing the latest message should keep showing it after a new one arrives.
Refactor this list page: - Tab, filters, sort, search, pagination cursor and any open panel or modal live in URL query params. Read with useSearchParams, write through the router; no mirrored useState. - Every modal is deep-linkable (?modal=...). Back must restore the previous view and its scroll position. - When older items load above the view, keep the row in view anchored: record the first visible row's offset before the update and restore it after; set overflow-anchor: none on the scroller. - Check the labels against what users search for and suggest renames before restructuring the nav.
Quick check
A user filters a table and sends the link to a colleague, who sees the unfiltered table. What's the fix?
In the skill
The nodes your agent reads when it does this work.
- URL as stateFilters, tabs, pagination, panels — all live in the URL. Back/forward restores everything including scroll.
layout/url-as-state - Information architectureStructure follows the user's mental model. Group long settings, tabs only for views of one thing, two levels of menu before breadcrumbs, search logs as rename requests, the primary action always in view, first value before any tour, errors that name a recovery.
layout/information-architecture - Scroll anchoringWhen content above the view changes height, keep the row the reader is looking at where it is on screen. Pick an anchor when the user scrolls, keep its screen offset until they scroll again, compare scroll positions by value (never by "a scroll event fired"), and store the scrollTop the browser read back.
layout/scroll-anchoring
Connected lessons
What this lesson builds on and leads to, across every course.