Exclusifbêta

Smart Header <mv-smart-header>

Enveloppez n’importe quel en-tête pour le rendre collant, le faire disparaître quand on défile vers le bas et revenir quand on remonte, avec hystérésis, un état collé et sa hauteur en direct publiée pour scroll-padding. Ce que les versions faites à la main oublient : il ne se masque jamais tant que le focus, un menu ouvert, un popover, une boîte de dialogue ou un <details> se trouve à l’intérieur, il reste en place pendant les sauts vers une ancre, il réapparaît quand un utilisateur du clavier y entre avec Tab, et il s’efface quand le focus, ailleurs, atterrirait dessous.

CatégorieNavigation
TypeWeb Component (<mv-smart-header>)
Statutbêta
Keywordsexclusive, light, header, sticky, hide on scroll, auto-hide, navbar, scroll, scroll-padding, focus not obscured

When to use

  • A content site or blog wants the top bar out of the way while reading and back as soon as the reader scrolls up
  • An existing header or navbar should become sticky and auto-hiding without rewriting its markup
  • Anchor links and keyboard focus must never end up hidden behind a sticky header
  • A header inside a scrollable panel or app pane should hide and reveal with that pane's scrolling

Avoid when

  • The page needs a full navigation bar with mega menus and a mobile drawer, not just scroll behavior → use Navbar instead
  • The header must always stay visible, such as an app toolbar with frequent actions
  • The goal is showing reading progress rather than moving the header → use Scroll Progress instead

Installation

node scripts/add.mjs smart-header --out ./src/marvelous

Agent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["smart-header"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/element.js, core/observe.js, components/smart-header/smart-header.js, components/smart-header/smart-header.css.

Utilisation

Démarrage rapide, le balisage minimal qui fonctionne :

<mv-smart-header>
  <header>…your header…</header>
</mv-smart-header>

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :

<div id="mv-smh-demo">
  <style>
    #mv-smh-demo { display: flex; flex-wrap: wrap; gap: 1rem; width: min(100%, 60rem); margin-inline: auto; align-items: flex-start; }
    #mv-smh-demo .page { flex: 1 1 26rem; min-width: 0; height: 480px; overflow: auto; overscroll-behavior: contain; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-xl); background: var(--mv-bg); }
    #mv-smh-demo .promo { padding: .5rem 1rem; text-align: center; font-size: .75rem; color: var(--mv-fg-muted); background: var(--mv-bg-muted); border-bottom: 1px solid var(--mv-border); }
    #mv-smh-demo .bar { display: flex; align-items: center; gap: 1rem; padding: .7rem 1.25rem; border-bottom: 1px solid var(--mv-border); }
    #mv-smh-demo .brand { display: flex; align-items: center; gap: .5rem; font-weight: 650; letter-spacing: -.01em; color: var(--mv-fg); text-decoration: none; }
    #mv-smh-demo .brand svg { width: 1.4rem; height: 1.4rem; color: var(--mv-accent); }
    #mv-smh-demo .bar nav { display: flex; gap: .15rem; margin-inline-start: auto; }
    #mv-smh-demo .bar nav a { padding: .35rem .6rem; border-radius: var(--mv-radius-md); font-size: .875rem; color: var(--mv-fg-muted); text-decoration: none; }
    #mv-smh-demo .bar nav a:hover { color: var(--mv-fg); background: var(--mv-bg-muted); }
    #mv-smh-demo .bar nav a:focus-visible { outline: 2px solid var(--mv-ring); outline-offset: 1px; }
    @container (max-width: 34rem) { #mv-smh-demo .bar nav a:not(:first-child) { display: none; } }
    #mv-smh-demo .page { container-type: inline-size; }
    #mv-smh-demo article { padding: 1.75rem 1.5rem 2.5rem; line-height: 1.65; font-size: .92rem; color: var(--mv-fg-muted); }
    #mv-smh-demo article h2 { margin: .35rem 0 .5rem; color: var(--mv-fg); font-size: 1.5rem; line-height: 1.2; letter-spacing: -.02em; text-wrap: balance; }
    #mv-smh-demo article h3 { margin: 1.6rem 0 .4rem; color: var(--mv-fg); font-size: 1.05rem; letter-spacing: -.01em; }
    #mv-smh-demo article p { margin: 0 0 .8rem; }
    #mv-smh-demo .eyebrow { font: 500 .7rem var(--mv-font-mono); letter-spacing: .06em; text-transform: uppercase; color: var(--mv-fg-subtle); }
    #mv-smh-demo .byline { font-size: .8rem; color: var(--mv-fg-subtle); }
    #mv-smh-demo .toc { display: flex; flex-wrap: wrap; gap: .4rem; margin: 1rem 0 .5rem; padding: 0; list-style: none; }
    #mv-smh-demo .toc a { display: inline-block; padding: .25rem .65rem; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-full); font-size: .78rem; color: var(--mv-fg); text-decoration: none; }
    #mv-smh-demo .toc a:hover { background: var(--mv-bg-muted); }
    #mv-smh-demo .toc a:focus-visible { outline: 2px solid var(--mv-ring); outline-offset: 1px; }
    #mv-smh-demo aside { flex: 1 1 14rem; display: grid; gap: .75rem; }
    #mv-smh-demo .card { padding: 1rem 1.1rem; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-lg); background: var(--mv-surface); }
    #mv-smh-demo dl { display: grid; grid-template-columns: auto 1fr; gap: .45rem 1rem; align-items: baseline; margin: 0; font-size: .85rem; }
    #mv-smh-demo dt { color: var(--mv-fg-muted); }
    #mv-smh-demo dd { margin: 0; justify-self: end; font: 500 .78rem var(--mv-font-mono); color: var(--mv-fg); }
    #mv-smh-demo dd[data-on] { color: var(--mv-accent); }
    #mv-smh-demo .tips { display: grid; gap: .5rem; margin: 0; padding: 0; list-style: none; font-size: .8rem; color: var(--mv-fg-muted); }
    #mv-smh-demo .tips b { color: var(--mv-fg); font-weight: 600; }
  </style>

  <div class="page" id="mv-smh-page" tabindex="0" aria-label="Scrollable demo page">
    <p class="promo" style="margin:0">Autumn issue out now · Print + digital for $49/year</p>
    <mv-smart-header id="mv-smh">
      <header class="bar">
        <a class="brand" href="#mv-smh-top"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>Atlas Review</a>
        <nav aria-label="Main">
          <a href="#mv-smh-top" aria-current="page">Stories</a>
          <a href="#mv-smh-top">Guides</a>
          <a href="#mv-smh-top">Events</a>
        </nav>
        <mv-menu>
          <button class="mv-button" data-variant="ghost" data-size="sm">Topics
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
          </button>
          <div data-content style="--mv-menu-width:12rem">
            <button role="menuitem" value="cities">Cities</button>
            <button role="menuitem" value="transit">Transit</button>
            <button role="menuitem" value="climate">Climate</button>
            <button role="menuitem" value="design">Design</button>
          </div>
        </mv-menu>
        <button class="mv-button" data-size="sm">Subscribe</button>
      </header>
    </mv-smart-header>

    <article id="mv-smh-top">
      <span class="eyebrow">Cities · Long read</span>
      <h2>How Lisbon rebuilt its tram network without closing a single line</h2>
      <p class="byline">By Inês Carvalho and Daniel Okafor · September 18, 2026 · 9 min read</p>
      <ul class="toc" aria-label="In this story">
        <li><a href="#mv-smh-routes">Routes</a></li>
        <li><a href="#mv-smh-fleet">The fleet</a></li>
        <li><a href="#mv-smh-riders">Riders</a></li>
      </ul>
      <p>When the city council approved a $420 million renewal plan in 2021, engineers were given one condition that sounded impossible: the yellow trams had to keep running while the tracks under them were replaced.</p>
      <p>Five years later, the network carries more passengers than at any point since the 1950s. Here is how a small team pulled it off, one street at a time.</p>
      <h3 id="mv-smh-routes">Routes, rebuilt at night</h3>
      <p>Crews worked in 300-meter segments between 1 a.m. and 5 a.m., laying prefabricated slabs that could carry traffic within hours. Each segment reopened before the first tram of the morning.</p>
      <p>The steepest stretch, on Calçada de São Francisco, took eleven nights instead of the planned twenty, thanks to a rail clamp designed by a local machine shop.</p>
      <p>Residents received a text message the evening before each closure, in Portuguese, English and Mandarin, with the exact hours and a detour map.</p>
      <h3 id="mv-smh-fleet">The fleet</h3>
      <p>Forty-five historic cars were rebuilt with regenerative brakes that feed energy back into the overhead line on every descent, cutting the network’s power bill by nearly a third.</p>
      <p>The wooden benches stayed. So did the brass bells, now tuned so that drivers on the same street can tell each other apart.</p>
      <h3 id="mv-smh-riders">Riders</h3>
      <p>Daily ridership passed 160,000 in July, led by commuters rather than tourists for the first time in a decade. A monthly pass still costs less than $45.</p>
      <p>“People trust it again,” says Mariana Sousa, who has driven line 28 for nineteen years. “They check the board, they see four minutes, and it is four minutes.”</p>
      <p>The next phase extends the network east along the river, with the first new line in sixty years scheduled to open in the spring of 2028.</p>
    </article>
  </div>

  <aside>
    <div class="card">
      <dl>
        <dt>Header</dt><dd id="mv-smh-state">Visible</dd>
        <dt>Content under it</dt><dd id="mv-smh-stuck">No</dd>
        <dt>Published height</dt><dd id="mv-smh-height">0px</dd>
      </dl>
    </div>
    <div class="card">
      <ul class="tips">
        <li><b>Scroll down</b> to read: the header slides away. Scroll up a little and it’s back.</li>
        <li><b>Open Topics</b>, then scroll: it stays while the menu is open.</li>
        <li><b>Tab</b> into it from the page: it comes back for keyboard users.</li>
        <li><b>Jump</b> with the chips: sections land just below it.</li>
      </ul>
    </div>
    <label class="mv-choice card" data-control="end">
      <input type="checkbox" role="switch" class="mv-switch" id="mv-smh-pin">
      <span class="mv-choice-text"><span class="mv-choice-title">Keep it pinned</span><span class="mv-choice-description">Sets the <code>pinned</code> attribute</span></span>
    </label>
  </aside>

  <script type="module">
    const header = document.getElementById("mv-smh");
    const page = document.getElementById("mv-smh-page");
    const state = document.getElementById("mv-smh-state");
    const stuck = document.getElementById("mv-smh-stuck");
    const height = document.getElementById("mv-smh-height");
    const render = () => {
      const hidden = header.hasAttribute("data-hidden");
      state.textContent = hidden ? "Hidden" : "Visible";
      state.toggleAttribute("data-on", hidden);
      stuck.textContent = header.hasAttribute("data-stuck") ? "Yes" : "No";
      height.textContent = page.style.getPropertyValue("--mv-smart-header-height") || "0px";
    };
    new MutationObserver(render).observe(header, { attributes: true, attributeFilter: ["data-hidden", "data-stuck"] });
    new MutationObserver(render).observe(page, { attributes: true, attributeFilter: ["style"] });
    document.getElementById("mv-smh-pin").addEventListener("change", (e) => { header.pinned = e.target.checked; });
    customElements.whenDefined("mv-smart-header").then(render);
  </script>
</div>

API

Attributes

NameTypeDefaultDescription
thresholdnumber(its own height)Distance (px) scrolled past the header's resting position before it may hide.
tolerancenumber8Distance (px) of continuous scrolling in one direction needed to hide or reveal, so small jitters and trackpad noise never flip it.
pinnedbooleanKeeps the header on screen (sticky only), e.g. during a checkout step.
data-stuckbooleanSet by the component once content scrolls under the header: translucent blurred surface and shadow by default.
data-hiddenbooleanSet by the component while the header is slid away (a transform: no layout shift).

Properties

NameTypeDescription
revealedbooleanTrue while the header is on screen (read-only).
stuckbooleanTrue once content scrolls under the header (read-only).

Methods

NameDescription
show()Brings the header back.
hide()Slides it away; ignored at rest, when pinned, or while focus or an open menu is inside.

Events

NameDescription
mv-hideBefore hiding; cancelable (preventDefault keeps it on screen). detail: { stuck }.
mv-showAfter revealing. detail: { stuck }.

Content structure

NameDescription
(child)Your own <header> (or <mv-navbar>): its landmark, markup and styles are untouched. Place <mv-smart-header> where the header sits, as a direct child of the scrolling content (usually <body>).

CSS variables

NameDefaultDescription
--mv-smart-header-heightLive height of the header in px, written on the scroll root (<html>, or the scrolling pane). Read-only; use it to offset other sticky elements.
--mv-smart-header-gapvar(--mv-space-3)Extra room above anchor targets, added to the height in the scroll-padding-top set on the scroll root.
--mv-smart-header-bgcolor-mix(var(--mv-bg) 82%, transparent)Surface once stuck.
--mv-smart-header-blurblur(14px) saturate(1.6)Backdrop filter once stuck (none to disable).
--mv-smart-header-shadowvar(--mv-shadow-md)Shadow once stuck.
--mv-smart-header-durationvar(--mv-duration-normal)Slide duration (0 under reduced motion).

Accessibility

Adds no role: the <header> or <nav> inside keeps its landmark. It never hides while keyboard focus or a focused field is inside it, while a menu, popover, dialog or <details> inside is open (aria-expanded="true", :popover-open, including popovertarget popovers that live elsewhere, open dialog or details, visible ones only), near the top, or during in-page anchor jumps. Hidden content stays in the tab order and focus entering it reveals the header; focus moving elsewhere onto an element the header would cover makes it step aside (WCAG 2.2 Focus Not Obscured). scroll-padding-top is set from its live height, so anchor targets and elements scrolled into view land below it. Reduced motion (OS or data-motion="reduce"): instant show and hide. Forced colors: a system-color rule under the stuck header. Print: static. Without JavaScript it is a plain sticky header.

Cette page a été traduite par IA. Signaler un problème de traduction