Exclusivobeta

Smart Header <mv-smart-header>

Envuelve cualquier cabecera para hacerla sticky, que se oculte al bajar y vuelva al subir, con histéresis, un estado fijado y su altura en vivo publicada para scroll-padding. Lo que las versiones hechas a mano pasan por alto: nunca se oculta mientras dentro haya foco, un menú abierto, un popover, un diálogo o un <details>, se mantiene durante los saltos a anclas, aparece cuando un usuario de teclado entra con Tab, y se aparta cuando el foco en otro lugar quedaría debajo de ella.

CategoríaNavegación
TipoWeb Component (<mv-smart-header>)
Estadobeta
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

Instalación

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

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["smart-header"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, core/element.js, core/observe.js, components/smart-header/smart-header.js, components/smart-header/smart-header.css.

Uso

Inicio rápido, el marcado mínimo que funciona:

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

Marcado de referencia: parte de él y personalízalo con atributos, data-* y 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

NameTipoDefaultDescription
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

NameTipoDescription
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.

Esta página se tradujo con IA. Informar de un problema de traducción