Design Lounge
Install

Nº 269 of 520

Hide-on-scroll navbar

A docs-site header with a dismissible announcement bar. It slides away when you scroll down, returns with a shadow when you scroll up, and underlines the section in view.

Preview

Open full size
1280 × 800 · 13.7 KB · 266 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Hide-on-scroll navbar" (piece navbar-hide-on-scroll) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

293 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

The header of Kestrel, a fictional feature-flag service. A 36px black announcement bar sits on top and can be dismissed. Under it is a 64px navbar with the logo, five section links, “Sign in” and a cobalt “Start free” button. Scroll down more than 8px and the whole header slides up out of view. Scroll up and it slides back with a soft shadow. As you pass each section, its link gets a 2px cobalt underline. The detail worth copying: the header only moves when the scroll delta passes 8px, so trackpad jitter never makes it flicker.

Reference behaviour

  1. First frame: scroll is 0. The announcement bar and the navbar are both visible. There is no shadow.
  2. The announcement reads “NEW Kestrel 4.2 evaluates flags at the edge in 11 regions. Read the changelog”. “NEW” is a cobalt tag. “Read the changelog” is an underlined link. An X button on the right dismisses it.
  3. “Product” is the active link. It has aria-current="true", ink text and a 2px cobalt underline.
  4. The user scrolls down. Each scroll event compares scrollY with the last recorded position. When the difference is more than 8px and the direction is down, and scrollY is past the header’s own height, the header gets translateY(-100%) over 280ms.
  5. The user scrolls up by more than 8px. The header returns to translateY(0) over 280ms and shows a shadow because the page is not at the top.
  6. At scrollY 0 the header is always shown and the shadow goes away.
  7. Moves under 8px do nothing. The last recorded position only updates when a move passes 8px.
  8. The header does not hide while the mobile menu is open, or while keyboard focus is inside it. If focus moves into a hidden header, it slides back in.
  9. As the reader scrolls, an IntersectionObserver watches the five sections. The section crossing a band 40% from the top of the viewport becomes active. Its link gets aria-current="true" and the underline grows from the left over 200ms. The old link loses both.
  10. Clicking a link scrolls to its section. Sections have scroll-margin-top: 64px so the heading clears the navbar when it reappears.
  11. Clicking the X removes the announcement bar from the page. The main content’s top padding animates from 100px to 64px over 280ms. Focus moves to the logo.
  12. Below 760px wide the five links, “Sign in” and “Start free” hide. A 44px menu button appears on the right. It opens a panel under the navbar with all links, “Sign in”, and “Start free” at full width. Escape closes it and returns focus to the button.

Structure

1280 x 800 frame, header fixed at top

+--------------------------------------------------------------------------+
| [NEW] Kestrel 4.2 evaluates flags at the edge in 11 regions. Read the changelog   [x] |  36px black
+--------------------------------------------------------------------------+
|  /\ Kestrel    Product  SDKs  Pricing  Changelog  Customers   Sign in [Start free ->] |  64px
|                ‾‾‾‾‾‾‾ 2px cobalt                                         |
+--------------------------------------------------------------------------+ 1px #e2e0d8
| main, padding-top 100px                                                  |
|   FEATURE FLAGS · V4.2                                                   |
|   Ship on Friday. Flip it off on Saturday.     [ code block ]            |
|   ...                                                                    |
|   02 · SDKs     | 2 x 2 cell grid |                                      |
|   03 · Pricing  ...                                                      |

inner width 1184px max, padding 0 32px
navbar grid: auto | minmax(0,1fr) | auto, gap 40px
announcement grid: 40px | minmax(0,1fr) | 40px
  • One header holds the announcement, the navbar and the mobile panel. It is position: fixed, so one transform moves all of it.
  • The announcement is a div with role="region" and aria-label="Announcement". Inside: a p and a button.
  • The navbar row holds a logo link, a nav labelled “Primary” with a ul of five links, and an actions group.
  • The actions group holds the “Sign in” link, the “Start free” link styled as a button, and the menu button.
  • main has top padding equal to the header height so the hero is not hidden on load.
  • Each section has an id matching a link href and a data-spy attribute.

Tokens

:root {
  /* surfaces */
  --bg: #f6f5f1;           /* page */
  --surface: #fbfaf7;      /* navbar, cells, panel */
  --note-bg: #141414;      /* announcement */
  --note-ink: #f6f5f1;

  /* ink */
  --ink: #141414;
  --ink-2: #4b4b48;        /* links at rest, body */
  --ink-3: #6b6b66;        /* labels, dates */
  --line: #e2e0d8;

  /* accent */
  --accent: #1f4fd8;       /* underline, button, tag, focus */
  --accent-hover: #173fb3;
  --accent-ink: #ffffff;

  /* type */
  --sans: "Hanken Grotesk", system-ui, sans-serif;
  --mono: "IBM Plex Mono", ui-monospace, monospace;
  --fs-note: 13px;
  --fs-link: 14px;
  --fs-logo: 18px;
  --fs-h1: 64px;
  --fs-h2: 40px;
  --fs-body: 16px;

  /* space */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-6: 24px;
  --space-8: 32px;
  --space-10: 40px;

  /* sizes */
  --note-h: 36px;
  --nav-h: 64px;
  --delta: 8px;

  /* radius and shadow */
  --radius: 6px;
  --shadow: 0 1px 0 rgba(20,20,20,.06), 0 8px 24px -12px rgba(20,20,20,.18);

  /* motion */
  --ease: cubic-bezier(.2,.7,.2,1);
  --dur: 280ms;
  --dur-micro: 200ms;
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
AnnouncementIBM Plex Mono13px40010sentence
NEW tagIBM Plex Mono11px40010.06emupper
LogoHanken Grotesk18px7001-0.02emtitle
Nav linkHanken Grotesk14px5001.550title
Sign inHanken Grotesk14px5001.550title
Start freeHanken Grotesk14px60010title
LabelIBM Plex Mono12px40010.08emupper
h1Hanken Grotesk64px7001.02-0.035emsentence
h2Hanken Grotesk40px7001.05-0.03emsentence
BodyHanken Grotesk16px4001.550sentence

Mono is for the announcement, labels, dates and code. Everything a user clicks in the navbar is in the grotesk.

Motion

ThingTriggerPropertyFrom → toDurationEasing
Header hidescroll down > 8px past header heighttransformtranslateY(0) → translateY(-100%)280ms--ease
Header revealscroll up > 8pxtransformtranslateY(-100%) → translateY(0)280ms--ease
Shadowheader shown and scrollY > 0box-shadownone → --shadow280ms--ease
Active underlinesection enters bandtransformscaleX(0) → scaleX(1), origin left200ms--ease
DismissX clickedmain padding-top100px → 64px280ms--ease
Button hoverhoverbackground#1f4fd8 → #173fb3160ms--ease
  • No delay, no stagger.
  • Reduced motion: remove the transitions on the header, main and the underline. The header still hides and shows, it just jumps. Scrollspy still updates.
  • Do not animate top or height. Only transform moves the header.

States

  • Header at top: shown, no shadow.
  • Header hidden: translateY(-100%), no shadow.
  • Header revealed mid-page: shown, with --shadow.
  • Link resting: #4b4b48.
  • Link hover: #141414.
  • Link active: #141414, aria-current="true", 2px cobalt bar 2px above the bottom edge, inset 12px each side.
  • Focus-visible: 2px cobalt outline, offset 2px, radius 2px. On the black announcement bar the ring is white and inset 4px.
  • Start free hover: #173fb3.
  • Announcement dismissed: removed from the DOM, not hidden with opacity.
  • Menu open: the panel shows under the navbar; the active link in the panel is cobalt text.
  • No disabled, loading or error state.

Accessibility

  • The header is a header. The links sit in a nav with aria-label="Primary".
  • The announcement has role="region" and aria-label="Announcement". The X is a button with aria-label="Dismiss announcement".
  • After dismissing, move focus to the logo so focus is not lost on a removed node.
  • Use aria-current="true" for the section link, not "page". All five links are on one page.
  • Keep exactly one link current in the bar and one in the panel.
  • Do not hide the header while focus is inside it. On focusin, reveal it. A keyboard user tabbing from the page back into the header must see it.
  • The menu button has aria-controls, aria-expanded and a label that switches between “Open menu” and “Close menu”. Escape closes the panel and returns focus.
  • Tab order: “Read the changelog”, the X, logo, five links, Sign in, Start free.
  • Contrast: #4b4b48 on #fbfaf7 is about 8:1. #1f4fd8 on #fbfaf7 is about 6.5:1. White on #1f4fd8 is about 6.8:1. #6b6b66 on #f6f5f1 is about 5:1.
  • Hit targets: links are 38px tall at 14px; the button is 40px; the X is 36px square; the menu button is 44px square.

Responsive rules

  • At 1280 and above: the full row. Grid gap 40px. Link padding 8px 12px.
  • At 1024 and below: link padding 8px 8px, grid gap 24px, h1 52px, the hero stacks to one column.
  • At 768: the row still fits: logo, five links, Sign in, Start free.
  • Below 760: hide the link nav, Sign in and Start free. Show the 44px menu button. The NEW tag hides. The announcement text drops to 12px and ends in an ellipsis when too long.
  • Below 640: page padding 0 20px. h1 40px. Cell grids stack to one column.
  • The hide and reveal logic is the same at every width.
  • No element ever overflows sideways. The code block scrolls inside itself with overflow-x: auto.

Acceptance checklist

Always

  • The header hides on scroll down and returns on scroll up.
  • Moves smaller than the delta do nothing.
  • At scroll 0 the header is always shown, with no shadow.
  • A revealed header mid-page has a shadow.
  • Only transform animates the header.
  • The section in view gets aria-current and an underline; the others do not.
  • The announcement can be dismissed; the content shifts up to close the gap; focus moves to a visible element.
  • The header never hides while focus is inside it or the menu is open.
  • Below the collapse width a 44px menu button opens a panel with every link and both actions.
  • Focus rings are visible on the dark bar and on the light bar.
  • Reduced motion keeps the behaviour but removes the slide.

This demo

  • The announcement is 36px, #141414, mono 13px, with a cobalt NEW tag.
  • The navbar is 64px, #fbfaf7, with a 1px #e2e0d8 rule.
  • Links: Product, SDKs, Pricing, Changelog, Customers. Product is current on load.
  • The delta is 8px. The slide is 280ms.
  • The observer uses rootMargin: "-40% 0px -55% 0px".
  • “Start free” is cobalt #1f4fd8, 40px tall, radius 6px.
  • Menu collapses below 760px.

Implementation notes

Always: move the announcement and the navbar together. One wrapper, one transform. Two fixed bars that hide separately drift apart by a frame.

The hide and reveal logic with the 8px delta:

const header = document.getElementById('top');
const DELTA = 8;
let lastY = scrollY, ticking = false;
function onScroll() {
  const y = scrollY, dy = y - lastY;
  if (Math.abs(dy) > DELTA) {
    const busy = panel.classList.contains('open') || header.contains(document.activeElement);
    if (dy > 0 && y > header.offsetHeight && !busy) header.classList.add('hidden');
    else if (dy < 0) header.classList.remove('hidden');
    lastY = y;
  }
  header.classList.toggle('raised', y > 0 && !header.classList.contains('hidden'));
  if (y <= 0) header.classList.remove('hidden');
  ticking = false;
}
addEventListener('scroll', () => { if (!ticking) { requestAnimationFrame(onScroll); ticking = true; } }, { passive: true });
header.addEventListener('focusin', () => header.classList.remove('hidden'));

Update lastY only inside the delta branch. If you update it on every event, slow scrolling never adds up to 8px and the header never hides.

The scrollspy:

const links = [...document.querySelectorAll('.links a, .panel a[href^="#"]')];
const spy = new IntersectionObserver(entries => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    links.forEach(a => a.getAttribute('href') === '#' + e.target.id
      ? a.setAttribute('aria-current', 'true')
      : a.removeAttribute('aria-current'));
  }
}, { rootMargin: '-40% 0px -55% 0px' });
document.querySelectorAll('[data-spy]').forEach(s => spy.observe(s));

The negative margins leave a 5% band near the upper middle of the viewport. Only one section can cross it at a time, so only one link is ever active.

The underline and header styles:

.top { position: fixed; inset: 0 0 auto 0; transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease); }
.top.hidden { transform: translateY(-100%); }
.top.raised { box-shadow: var(--shadow); }
.links a::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: 2px; height: 2px;
  background: var(--accent); transform: scaleX(0); transform-origin: left; transition: transform 200ms var(--ease); }
.links a[aria-current="true"]::after { transform: scaleX(1); }
[data-spy] { scroll-margin-top: var(--nav-h); }

Common mistakes:

  • Hiding on every downward pixel, so the header twitches on a trackpad.
  • Hiding the header at scroll 0 on a bounce.
  • Using position: sticky and animating top. It reflows the page.
  • Leaving the shadow on at the top of the page.
  • Hiding the announcement with opacity: 0 so it still takes 36px and still takes focus.
  • Leaving main padding at 100px after the announcement is gone.
  • Two active links during the handoff because the observer band is too tall.
  • aria-current="page" on in-page section links.
  • A gradient or a glow on the Start free button. It is flat cobalt.
  • A second accent colour for the NEW tag. It is the same cobalt.

Rebuild order:

  1. Build the fixed header with the announcement and the 64px navbar.
  2. Pad main by the header height.
  3. Add the five sections with matching ids.
  4. Add the hide and reveal logic with the 8px delta.
  5. Add the shadow rule.
  6. Add the scrollspy and the underline.
  7. Wire the dismiss button and the padding change.
  8. Add the 1024 and 760 breakpoints and the menu panel.
  9. Tab through the header from the bottom of the page and confirm it reveals.
  10. Turn on reduced motion and confirm the header jumps instead of sliding.

Details

Palette
Type
Hanken Grotesk · IBM Plex Mono
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Lama Lama

A tiny nav bar can carry the voice. Change its one line for each section.

All sources

On the shelves