Exclusivobeta
Smart Header <mv-smart-header>
Envolva qualquer cabeçalho para deixá-lo fixo, fazê-lo sumir ao rolar para baixo e voltar ao rolar para cima, com histerese, um estado stuck e sua altura ao vivo publicada para o scroll-padding. O que as versões feitas à mão esquecem: ele nunca se esconde enquanto o foco, um menu aberto, um popover, um diálogo ou um <details> estiver dentro dele, fica parado durante os saltos para âncoras, reaparece quando um usuário de teclado entra nele com Tab, e sai do caminho quando o foco em outro lugar cairia embaixo dele.
| Categoria | Navegação |
|---|---|
| Tipo | Web Component (<mv-smart-header>) |
| Status | beta |
| Keywords | exclusive, 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
Instalação
node scripts/add.mjs smart-header --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["smart-header"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): 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
Início rápido, a menor marcação que funciona:
<mv-smart-header>
<header>…your header…</header>
</mv-smart-header>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis 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
| Name | Tipo | Default | Description |
|---|---|---|---|
threshold | number | (its own height) | Distance (px) scrolled past the header's resting position before it may hide. |
tolerance | number | 8 | Distance (px) of continuous scrolling in one direction needed to hide or reveal, so small jitters and trackpad noise never flip it. |
pinned | boolean | Keeps the header on screen (sticky only), e.g. during a checkout step. | |
data-stuck | boolean | Set by the component once content scrolls under the header: translucent blurred surface and shadow by default. | |
data-hidden | boolean | Set by the component while the header is slid away (a transform: no layout shift). |
Properties
| Name | Tipo | Description |
|---|---|---|
revealed | boolean | True while the header is on screen (read-only). |
stuck | boolean | True once content scrolls under the header (read-only). |
Methods
| Name | Description |
|---|---|
show() | Brings the header back. |
hide() | Slides it away; ignored at rest, when pinned, or while focus or an open menu is inside. |
Events
| Name | Description |
|---|---|
mv-hide | Before hiding; cancelable (preventDefault keeps it on screen). detail: { stuck }. |
mv-show | After revealing. detail: { stuck }. |
Content structure
| Name | Description |
|---|---|
(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
| Name | Default | Description |
|---|---|---|
--mv-smart-header-height | Live 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-gap | var(--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-bg | color-mix(var(--mv-bg) 82%, transparent) | Surface once stuck. |
--mv-smart-header-blur | blur(14px) saturate(1.6) | Backdrop filter once stuck (none to disable). |
--mv-smart-header-shadow | var(--mv-shadow-md) | Shadow once stuck. |
--mv-smart-header-duration | var(--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.