People do not realise the landing page continues below the fold, and do not realise the product results list continues below the last visible row. They stop, assume that is everything, and leave.
This is a named, well-studied failure: the illusion of completeness, also called a false bottom. NN/g's four documented causes are large hero graphics, horizontal dividing lines, excessive white space, and content interruptions. We hit the first and third.
Where we hit it
Landing. frontend/src/app/(root)/components/sections/hero-section.tsx:10 is min-h-[70dvh], and frontend/src/app/(root)/page.tsx:22 stacks eleven sections with space-y-14. A tall hero plus generous gaps is exactly the pattern NN/g describes. Whether the next section actually peeks depends on the device, which is why it fails for some people and not others.
Product results. frontend/src/app/products/hooks/use-infinite-products.ts:130-154 appends batches on scroll with a 10000px prefetch margin. It works, but there is no visual signal that anything is coming, no end-of-results marker, and no signal when isTruncated caps the list at PRODUCT_SEARCH_LIMIT (100). The list simply stops, and a list that stops looks finished.
What the research says
Worth reading before implementing, because it argues against the obvious fix as much as for it.
- NN/g's own remedy is layout, not decoration. Make content "peek above the fold to lead users to scroll further". They single out Netflix showing items "bleeding off the screen" as the pattern that works, and call out cues that are weak or hidden as ineffective: "using directional cues is not enough, you have to use strong directional cues."
- Custom scroll indicators mostly go unseen. Viget's eye-tracking study with Tobii Pro, 24 participants plus a 100-participant follow-up, found that during uninterrupted reading only 4 of 18 participants fixated on the custom indicator at all. Their conclusion: custom scroll indicators "may not be worth the development effort and page weight". Note the scope, though: they tested a passive progress indicator on a long article, not a tappable control on a first screen. That difference is the strongest argument for the version proposed below.
- Load more beats infinite scroll on product lists, per Baymard: more products viewed than with pagination, more attention paid per product than with infinite scroll. Explicitly out of scope here by decision, recorded so the next person does not re-derive it. The list keeps its current mechanics.
- The animated hero chevron is a genre convention on marketing sites, but the published evidence for it is thin. Nobody has shown it does much on its own. Combined with a strong layout cue, it is cheap insurance.
Decided approach
Two complementary things, both on the landing page and the product results list.
1. A fixed bottom edge fade
Reuse what we already have rather than inventing a look. frontend/src/components/custom/common/edge-fade.tsx is already presentational and already documents fixed as a supported positioning override; scroll-fade.tsx wraps it with "is there more?" detection. It is used today on ui/select.tsx, multi-select-panel.tsx, app-sidebar.tsx and sidebar-filter-menu.tsx, so people who have used the app have already been taught what a soft bottom edge means here. Extending that vocabulary to the page itself is nearly free.
Implementation catch: ScrollFade takes a targetRef to an element and reads scrollTop/clientHeight/scrollHeight off it. The window is not that element. This needs either a window-scroll variant or a small extension that accepts the document scrolling element. Do not paper over it by wrapping the page in a scroll container, which would break scroll restoration, the URL bar collapse on mobile, and position: sticky in the header.
It must be pointer-events-none (EdgeFade already is), must sit correctly against the bottom nav on mobile rather than fading into it, and must be aria-hidden (already is).
2. A tappable scroll-cue button
A circular double-chevron-down control, centred horizontally near the bottom of the first screen.
-
Visible only at scrollY === 0. The moment the page moves, the point is made and it goes away.
-
Tapping scrolls one screen down. This is the part that earns it. A moving thing that does nothing is decoration, and Viget's finding is largely about decoration; a moving thing that is also a control is an affordance. It also gives the cue an accessible, keyboard-operable equivalent instead of being a picture of an instruction.
-
Motion: jump a few times, then rest. Decided. It attracts the eye and then stops competing with the page. It must be fully suppressed under prefers-reduced-motion (motion-reduce:), per the accessibility checklist in AGENTS.md.
-
Signed-out sessions only. Someone with an account has already learned the app and does not need to be taught that pages scroll. Gate on useUser() from frontend/src/context/user-context.tsx, and gate on its isLoading too: the session resolves asynchronously, so a naive check renders the cue for a moment and then yanks it away from every logged-in user on every page load. Same shape as the ready flag in use-install-prompt.ts, which exists for exactly this reason. Render nothing until the session is known. This matters more for the product list than the landing page, since the landing page is mostly seen signed-out anyway. Taken to apply to both cues. If the fade turns out to look good enough to keep for everyone, that is a one-line change and worth raising rather than doing silently.
Reuse, do not rebuild:
frontend/src/utils/scroll.ts already has scrollWindowTo(top), which honours prefers-reduced-motion explicitly because browsers apply it to CSS scroll-behavior but not to scrollTo options. Scroll to roughly window.scrollY + window.innerHeight (slightly less, so a sliver of the current screen carries over and the jump is legible as a scroll rather than a page swap).
frontend/src/hooks/use-scrolled-past.ts reads scroll position via useSyncExternalStore rather than mirroring it into state, so there is no render with a stale answer. useScrolledPast(0), inverted, is this component's visibility.
frontend/src/components/custom/fab/back-to-top-button.tsx is the template for everything else: inert when hidden (which clears the tab order and the a11y tree, where opacity alone does not), z-[var(--z-fab)], a pointer-events-none wrapper so the hidden button leaves no dead zone, and explicit rem sizes because this project sets --spacing: 0.2rem and numeric size utilities therefore render smaller than their usual pixel equivalents.
Stacking order. The cue must never sit on top of anything the user has deliberately opened. Concretely it has to render below the TanStack Query devtools panel and below any sheet, including the search sheet and the filters sheet.
The good news is that the existing z-scale in frontend/src/app/globals.css:633-645 already encodes this, so the requirement is satisfied by using the token rather than by inventing a number. --z-fab is 42, which is already beneath every layer that matters:
| Layer |
Token |
Value |
| Offline banner |
--z-offline |
60 |
| Dialog / overlay |
--z-overlay |
50 |
| Bottom nav |
--z-bottom-nav |
45 |
| Sheets |
--z-bottom-sheet |
44 |
| Sheet scrim |
--z-bottom-sheet-scrim |
43 |
| This button |
--z-fab |
42 |
| Install banner |
--z-install-banner |
41 |
| Scroll fade |
--z-scroll-fade |
40 |
So: use z-[var(--z-fab)], exactly as back-to-top-button.tsx already does, and do not hand-write a z-index. Note the fade in the same table: --z-scroll-fade is 40, below both the button and the install banner, which is the right place for a non-interactive gradient.
The TanStack devtools (frontend/src/app/providers/react-query-provider.tsx:54) render into their own portal at a very high z-index we do not control and cannot exceed, so being below them is the default and needs no work. They are development-only and never ship to users, but the button still needs to not fight them locally.
Being visually beneath a sheet is necessary but not sufficient: while a sheet is open the button must also not be focusable or clickable. Radix already applies aria-hidden and pointer-events: none to outside content for a modal sheet, so this should come for free, but confirm it with a keyboard rather than assuming it, especially given the button is only visible at scrollY === 0, which is precisely when a sheet is most likely to be opened.
Collisions to check. This is the part most likely to go wrong, and there is prior art: #137 was the install banner and the desktop FAB fighting over the bottom-right corner.
BackToTopButton is bottom-right, desktop-only, and appears past 600px. The scroll cue appears only at 0, so the two can never be on screen together. Confirm that rather than assume it.
- The install banner (
install-banner.tsx) floats and is dismissible.
- On mobile the bottom nav owns the bottom of the screen, and its raised centre item is a green circle. A second circular control directly above it is a real risk, both visually and because people may read the two as related. Centre-bottom may need to become centre-lower-third on mobile, or the shape may need to differ from the nav's disc.
Also worth doing, not chosen
Making the next landing section peek above the fold is NN/g's own primary remedy and the only one with strong evidence behind it, and it costs a padding change rather than a component. It was not selected for this issue. Flagging it here so it is a decision on the record rather than an oversight: if the fade and the chevron under-deliver, tune min-h-[70dvh] and the inter-section spacing before adding anything louder.
Acceptance
- Landing at rest on a phone and on a desktop shows both cues; scrolling one pixel hides the chevron.
- Tapping the chevron advances roughly one screen and it disappears.
- Keyboard: the chevron is reachable and activates; when hidden it is not in the tab order.
prefers-reduced-motion: no jumping, and the scroll is instant rather than smooth.
- Screen reader: the chevron has a Croatian accessible name in second person and ungendered form; the fade is not announced.
- Signed in: neither cue ever appears, and there is no flash of it during session resolution on a hard reload.
- Opening the search sheet or the filters sheet covers the button; it is not visible, clickable, or reachable by keyboard while a sheet is open.
- In development, the TanStack devtools panel renders above the button, not behind it.
- No overlap with the bottom nav, the install banner, or
BackToTopButton at any breakpoint.
docs/LANDING.md gets a line about the cue once it lands.
People do not realise the landing page continues below the fold, and do not realise the product results list continues below the last visible row. They stop, assume that is everything, and leave.
This is a named, well-studied failure: the illusion of completeness, also called a false bottom. NN/g's four documented causes are large hero graphics, horizontal dividing lines, excessive white space, and content interruptions. We hit the first and third.
Where we hit it
Landing.
frontend/src/app/(root)/components/sections/hero-section.tsx:10ismin-h-[70dvh], andfrontend/src/app/(root)/page.tsx:22stacks eleven sections withspace-y-14. A tall hero plus generous gaps is exactly the pattern NN/g describes. Whether the next section actually peeks depends on the device, which is why it fails for some people and not others.Product results.
frontend/src/app/products/hooks/use-infinite-products.ts:130-154appends batches on scroll with a 10000px prefetch margin. It works, but there is no visual signal that anything is coming, no end-of-results marker, and no signal whenisTruncatedcaps the list atPRODUCT_SEARCH_LIMIT(100). The list simply stops, and a list that stops looks finished.What the research says
Worth reading before implementing, because it argues against the obvious fix as much as for it.
Decided approach
Two complementary things, both on the landing page and the product results list.
1. A fixed bottom edge fade
Reuse what we already have rather than inventing a look.
frontend/src/components/custom/common/edge-fade.tsxis already presentational and already documentsfixedas a supported positioning override;scroll-fade.tsxwraps it with "is there more?" detection. It is used today onui/select.tsx,multi-select-panel.tsx,app-sidebar.tsxandsidebar-filter-menu.tsx, so people who have used the app have already been taught what a soft bottom edge means here. Extending that vocabulary to the page itself is nearly free.Implementation catch:
ScrollFadetakes atargetRefto an element and readsscrollTop/clientHeight/scrollHeightoff it. The window is not that element. This needs either a window-scroll variant or a small extension that accepts the document scrolling element. Do not paper over it by wrapping the page in a scroll container, which would break scroll restoration, the URL bar collapse on mobile, andposition: stickyin the header.It must be
pointer-events-none(EdgeFadealready is), must sit correctly against the bottom nav on mobile rather than fading into it, and must bearia-hidden(already is).2. A tappable scroll-cue button
A circular double-chevron-down control, centred horizontally near the bottom of the first screen.
Visible only at
scrollY === 0. The moment the page moves, the point is made and it goes away.Tapping scrolls one screen down. This is the part that earns it. A moving thing that does nothing is decoration, and Viget's finding is largely about decoration; a moving thing that is also a control is an affordance. It also gives the cue an accessible, keyboard-operable equivalent instead of being a picture of an instruction.
Motion: jump a few times, then rest. Decided. It attracts the eye and then stops competing with the page. It must be fully suppressed under
prefers-reduced-motion(motion-reduce:), per the accessibility checklist inAGENTS.md.Signed-out sessions only. Someone with an account has already learned the app and does not need to be taught that pages scroll. Gate on
useUser()fromfrontend/src/context/user-context.tsx, and gate on itsisLoadingtoo: the session resolves asynchronously, so a naive check renders the cue for a moment and then yanks it away from every logged-in user on every page load. Same shape as thereadyflag inuse-install-prompt.ts, which exists for exactly this reason. Render nothing until the session is known. This matters more for the product list than the landing page, since the landing page is mostly seen signed-out anyway. Taken to apply to both cues. If the fade turns out to look good enough to keep for everyone, that is a one-line change and worth raising rather than doing silently.Reuse, do not rebuild:
frontend/src/utils/scroll.tsalready hasscrollWindowTo(top), which honoursprefers-reduced-motionexplicitly because browsers apply it to CSSscroll-behaviorbut not toscrollTooptions. Scroll to roughlywindow.scrollY + window.innerHeight(slightly less, so a sliver of the current screen carries over and the jump is legible as a scroll rather than a page swap).frontend/src/hooks/use-scrolled-past.tsreads scroll position viauseSyncExternalStorerather than mirroring it into state, so there is no render with a stale answer.useScrolledPast(0), inverted, is this component's visibility.frontend/src/components/custom/fab/back-to-top-button.tsxis the template for everything else:inertwhen hidden (which clears the tab order and the a11y tree, where opacity alone does not),z-[var(--z-fab)], apointer-events-nonewrapper so the hidden button leaves no dead zone, and explicitremsizes because this project sets--spacing: 0.2remand numeric size utilities therefore render smaller than their usual pixel equivalents.Stacking order. The cue must never sit on top of anything the user has deliberately opened. Concretely it has to render below the TanStack Query devtools panel and below any sheet, including the search sheet and the filters sheet.
The good news is that the existing z-scale in
frontend/src/app/globals.css:633-645already encodes this, so the requirement is satisfied by using the token rather than by inventing a number.--z-fabis 42, which is already beneath every layer that matters:--z-offline--z-overlay--z-bottom-nav--z-bottom-sheet--z-bottom-sheet-scrim--z-fab--z-install-banner--z-scroll-fadeSo: use
z-[var(--z-fab)], exactly asback-to-top-button.tsxalready does, and do not hand-write a z-index. Note the fade in the same table:--z-scroll-fadeis 40, below both the button and the install banner, which is the right place for a non-interactive gradient.The TanStack devtools (
frontend/src/app/providers/react-query-provider.tsx:54) render into their own portal at a very high z-index we do not control and cannot exceed, so being below them is the default and needs no work. They are development-only and never ship to users, but the button still needs to not fight them locally.Being visually beneath a sheet is necessary but not sufficient: while a sheet is open the button must also not be focusable or clickable. Radix already applies
aria-hiddenandpointer-events: noneto outside content for a modal sheet, so this should come for free, but confirm it with a keyboard rather than assuming it, especially given the button is only visible atscrollY === 0, which is precisely when a sheet is most likely to be opened.Collisions to check. This is the part most likely to go wrong, and there is prior art: #137 was the install banner and the desktop FAB fighting over the bottom-right corner.
BackToTopButtonis bottom-right, desktop-only, and appears past 600px. The scroll cue appears only at 0, so the two can never be on screen together. Confirm that rather than assume it.install-banner.tsx) floats and is dismissible.Also worth doing, not chosen
Making the next landing section peek above the fold is NN/g's own primary remedy and the only one with strong evidence behind it, and it costs a padding change rather than a component. It was not selected for this issue. Flagging it here so it is a decision on the record rather than an oversight: if the fade and the chevron under-deliver, tune
min-h-[70dvh]and the inter-section spacing before adding anything louder.Acceptance
prefers-reduced-motion: no jumping, and the scroll is instant rather than smooth.BackToTopButtonat any breakpoint.docs/LANDING.mdgets a line about the cue once it lands.