payloadcms / payloadcms/payload
Admin nav is force-collapsed at ≤1440px and does not persist user preference
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 44.8k
- Forks
- 4.2k
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 53
Description
Summary
On viewports of 1440 px or narrower, the admin navigation is force-closed on
every mount, the user's stored nav preference is never read, and toggling the nav
never writes one. The behaviour is invisible above 1440 px.
The server renders the nav open; the collapse happens client-side after
hydration. For any project whose desktop breakpoint is 1440 px, the sidebar is
therefore never open on first load, and "remember my choice" does not exist.
Version
payload |
3.86.0 |
@payloadcms/ui |
3.86.0 |
@payloadcms/next |
3.86.0 |
| Next.js | 16.2.6 |
| React | 19.2.3 |
Reproduction
- Scaffold any Payload 3.86 app with the default admin (no custom
Nav, no
custom providers). - Run a production build and start it (
next build && next start). - Sign in, set the viewport to 1441 px, confirm the sidebar is open, and
leave it open. - Reload at 1440 px.
Expected: the sidebar respects the stored preference (open).
Actual: it is collapsed. Toggling it open and reloading does not persist either —
at this width no preference is written.
Reading the computed grid of .template-default:
1441px -> grid-template-columns: 242px ... (nav visible)
1440px -> grid-template-columns: 0px ... (nav collapsed)
1024px -> grid-template-columns: 0px ... (nav collapsed)
The boundary is exact: one CSS pixel changes the outcome. Reproduced identically
on a dev server and on a production build.
Server-side, @payloadcms/next/dist/layouts/Root/index.js renders it open:
isNavOpen: navPrefs?.open ?? true,
Source locations
1. Breakpoints are hardcoded — @payloadcms/ui/dist/providers/Root/index.js:
<WindowInfoProvider breakpoints={{
l: "(max-width: 1440px)",
m: "(max-width: 1024px)",
s: "(max-width: 768px)",
xs: "(max-width: 400px)",
}}>
There is no payload.config.ts key that reaches these.
2. Unconditional close at or below l — @payloadcms/ui/dist/elements/Nav/context.js:
// on smaller screens where the nav is a modal
// close the nav when the user resizes down to mobile
useEffect(() => {
if (largeBreak === true || midBreak === true || smallBreak === true) {
setNavOpen(false)
}
setHydrated(true)
...
}, [largeBreak, midBreak, smallBreak])
l is 1440 px, but the comment describes mobile/modal behaviour. That mismatch
looks like the root of it: the rule intended for modal widths is applied to a
width most projects treat as desktop.
3. The preference is only READ above l — same file:
useEffect(() => {
if (largeBreak === false) {
const setNavFromPreferences = async () => {
const preferredState = await getNavPreference(getPreference)
setNavOpen(preferredState)
}
void setNavFromPreferences()
}
}, [largeBreak, getPreference, setNavOpen])
4. ...and only WRITTEN above l — @payloadcms/ui/dist/elements/Nav/NavToggler/index.js:
setNavOpen(!navOpen)
if (!largeBreak) {
await setPreference(PREFERENCE_KEYS.NAV, { open: !navOpen }, true)
}
Workarounds that do not work
| Attempt | Outcome |
|---|---|
| Configure the breakpoints | No configuration key exists. |
Custom provider calling setNavOpen(true) via the public useNav() hook |
admin.components.providers render as children of NavProvider. React flushes child effects before parent effects, so Payload's close runs last and wins. Beating it requires a timer or a second scheduled pass. |
Same logic inside a replacement admin.components.Nav |
Also a child of NavProvider. Same ordering. |
| CSS forcing the grid column open | Desynchronises the toggler, whose accessible name is derived from navOpen ("Open Menu" / "Close Menu"). The control would then misreport its own state — a WCAG 4.1.2 (Name, Role, Value) problem introduced to fix a layout one. |
| Patch the package | Not maintainable across upgrades. |
Accessibility impact
Navigation stays reachable — the toggler is present and operable, so this is
not a blocker. But at 1440 px and below, reaching any admin section costs an
extra interaction on every page load, permanently, with no way for a user to opt
out by expressing a preference. The one purely-CSS workaround makes it worse by
desynchronising the toggler's accessible name from its actual state, so there is
no accessible fix available at application level.
Desired behaviour
Any one of these would resolve it:
- Make the breakpoints configurable — e.g.
admin.breakpointsin
payload.config.ts, defaulting to today's values. Smallest change, widest
benefit; several projects define "desktop" at 1440 px. - Read the stored preference at every width, and keep the unconditional
close for the genuinely modal widths only (m/s), where a persistent open
state is meaningless. - Expose
initialIsOpenas an option onDefaultTemplateso an application
can state its own default without reimplementing the provider.
Option 2 alone would give: open by default on desktop first visit, an explicit
collapse remembered, and modal-collapsed on mobile — the behaviour Payload
already implements above 1440 px.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with @payloadcms/ui/dist/providers/Root/index.js, @payloadcms/ui/dist/elements/Nav/context.js, and @payloadcms/ui/dist/elements/Nav/NavToggler/index.js. Reproduce the preference behavior at 1440px and compare it with the existing behavior above that breakpoint; done means the chosen behavior preserves the nav state and keeps the toggler's accessible name synchronized.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- nextjs, react, typescript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100