Exclusivebeta
Back to Top <mv-back-to-top>
Floating back-to-top button that appears only once the reader is well down the page and scrolls up or pauses, never while reading down, with an optional progress ring. What hand-made versions miss: it moves keyboard focus to the top (the next Tab starts there, not at the bottom), jumps instantly under reduced motion, lifts itself above chat launchers or cookie banners, and works inside any scroll pane.
| Category | Scroll |
|---|---|
| Type | Web Component (<mv-back-to-top>) |
| Status | beta |
| Keywords | exclusive, light, back to top, scroll to top, floating button, fab, progress ring, focus management, skip link, scroll |
When to use
- Long articles, docs or changelogs need a way back to the top that does not cover the text while people read
- An infinite feed or search results list should let people return to the top and keep tabbing from there
- A page already has a chat launcher or cookie banner in the bottom corner that the button must not sit on
- A scrollable app pane, drawer or modal needs its own back-to-top control
Avoid when
- The goal is showing how far the reader has scrolled, with no action attached → use Scroll Progress instead
- Readers need to jump between sections, not just back to the start → use Reading Map instead
- The page is short enough to fit in about two screens: the button would add clutter with no benefit
Install
node scripts/add.mjs back-to-top --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["back-to-top"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/element.js, core/motion.js, core/observe.js, components/back-to-top/back-to-top.js, components/back-to-top/back-to-top.css.
Usage
Quick start, the smallest working markup:
<mv-back-to-top></mv-back-to-top>Canonical markup, to start from and customize with attributes, data-* and CSS variables:
<div id="mv-btt-demo">
<style>
#mv-btt-demo { display: flex; flex-wrap: wrap; gap: 1rem; width: min(100%, 60rem); margin-inline: auto; align-items: flex-start; }
#mv-btt-demo .col { flex: 1 1 22rem; min-width: 0; display: grid; gap: .6rem; }
#mv-btt-demo .cap { display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 1.75rem; font-size: .8rem; color: var(--mv-fg-muted); }
#mv-btt-demo .cap b { color: var(--mv-fg); font-weight: 600; }
#mv-btt-demo code { font: 500 .72rem var(--mv-font-mono); color: var(--mv-fg-subtle); }
#mv-btt-demo .pane { height: 440px; overflow: auto; overscroll-behavior: contain; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-xl); background: var(--mv-bg); }
#mv-btt-demo .pane:focus-visible { outline: 2px solid var(--mv-ring); outline-offset: 2px; }
#mv-btt-demo article { padding: 1.5rem 1.5rem 2rem; line-height: 1.65; font-size: .9rem; color: var(--mv-fg-muted); }
#mv-btt-demo article h2 { margin: .3rem 0 .6rem; color: var(--mv-fg); font-size: 1.4rem; line-height: 1.2; letter-spacing: -.02em; }
#mv-btt-demo article h3 { margin: 1.5rem 0 .35rem; color: var(--mv-fg); font-size: 1rem; letter-spacing: -.01em; }
#mv-btt-demo article p { margin: 0 0 .75rem; }
#mv-btt-demo .eyebrow { font: 500 .7rem var(--mv-font-mono); letter-spacing: .06em; text-transform: uppercase; color: var(--mv-fg-subtle); }
#mv-btt-demo .toc { display: flex; flex-wrap: wrap; gap: .4rem; margin: 0 0 1rem; padding: 0; list-style: none; }
#mv-btt-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-btt-demo .toc a:hover { background: var(--mv-bg-muted); }
#mv-btt-demo .toc a:focus-visible { outline: 2px solid var(--mv-ring); outline-offset: 1px; }
#mv-btt-demo .shell { position: relative; }
#mv-btt-demo .feed { margin: 0; padding: .5rem 0 5rem; list-style: none; }
#mv-btt-demo .feed li { display: grid; grid-template-columns: 2.25rem 1fr auto; gap: .75rem; align-items: center; padding: .7rem 1.1rem; border-bottom: 1px solid var(--mv-border); font-size: .85rem; }
#mv-btt-demo .feed .av { display: grid; place-items: center; width: 2.25rem; height: 2.25rem; border-radius: var(--mv-radius-full); background: var(--mv-bg-muted); color: var(--mv-fg); font-size: .72rem; font-weight: 600; }
#mv-btt-demo .feed b { color: var(--mv-fg); font-weight: 600; }
#mv-btt-demo .feed small { display: block; color: var(--mv-fg-subtle); font-size: .75rem; }
#mv-btt-demo .feed .amt { font: 500 .8rem var(--mv-font-mono); color: var(--mv-fg); }
#mv-btt-demo .chat { position: absolute; inset: auto 1.25rem 1.25rem auto; display: grid; place-items: center; width: 3.25rem; height: 3.25rem; border: 0; border-radius: var(--mv-radius-full); background: var(--mv-accent); color: var(--mv-fg-on-accent); box-shadow: var(--mv-shadow-lg); cursor: pointer; }
#mv-btt-demo .chat[hidden] { display: none; }
#mv-btt-demo .chat svg { width: 1.4rem; height: 1.4rem; }
#mv-btt-demo .chat:focus-visible { outline: none; box-shadow: var(--mv-focus-ring); }
#mv-btt-demo .status { margin: 0; font-size: .78rem; color: var(--mv-fg-muted); min-height: 1.2em; }
</style>
<div class="col">
<div class="cap"><span><b>Docs page</b> · progress ring, focus lands on the title</span><code>progress target</code></div>
<div class="pane" id="mv-btt-docs" tabindex="0" aria-label="Guide: shipping internationally">
<article>
<span class="eyebrow">Guide · 7 min read</span>
<h2 id="mv-btt-docs-title">Shipping internationally</h2>
<ul class="toc" aria-label="On this page">
<li><a href="#mv-btt-docs-rates">Rates</a></li>
<li><a href="#mv-btt-docs-duties">Duties</a></li>
<li><a href="#mv-btt-docs-returns">Returns</a></li>
</ul>
<p>Selling abroad starts with three decisions: which countries you ship to, who pays import duties, and how returns travel back. This guide walks through each one with the defaults most stores pick first.</p>
<p>Scroll down to read. The button stays out of the way while you read, and comes back as soon as you scroll up or pause.</p>
<h3 id="mv-btt-docs-rates">Rates</h3>
<p>Flat rates are the easiest to explain: $12 to Canada and Mexico, $18 to Europe, $24 to Asia-Pacific. Customers see the price before checkout and support tickets drop.</p>
<p>Calculated rates follow the carrier's live price for the parcel's weight and size. They are fairer on heavy orders but make totals harder to predict for the buyer.</p>
<p>Whichever you choose, offer a free-shipping threshold per region. A $150 threshold in Europe and $200 in Australia keeps the average order value healthy.</p>
<h3 id="mv-btt-docs-duties">Duties</h3>
<p>With delivered duty paid (DDP), you collect duties and taxes at checkout and the parcel arrives with nothing left to pay. It is the best experience, and the one buyers now expect.</p>
<p>With delivered at place (DAP), the courier asks the recipient to pay on delivery. Refused parcels come back at your expense, so reserve it for low-value markets.</p>
<p>Show the full landed cost in the cart: product, shipping, duties and taxes. A buyer in Oslo who sees $86 at checkout should pay $86, not $86 plus a surprise at the door.</p>
<p>Keep HS codes on every product. Customs uses them to set the duty rate, and a wrong code is the most common reason a parcel is held at the border.</p>
<h3 id="mv-btt-docs-returns">Returns</h3>
<p>International returns cost more than the original shipment. Many stores offer a refund without return under $40, and a prepaid label above it.</p>
<p>Consolidating returns in a regional hub, for example one in Rotterdam for all of Europe, lets you ship them home in a single pallet each month.</p>
<p>Publish the return window in days, not in "weeks", and say whether it starts at delivery or at shipment. Clear rules prevent most disputes.</p>
<p>Finally, test the whole flow yourself: order to a friend abroad, pay the duties, send it back. You will find the rough edges before your customers do.</p>
<p>You reached the end of the guide. Use the button to go back to the top: the next Tab lands on the first link under the title.</p>
</article>
<mv-back-to-top progress target="#mv-btt-docs-title"></mv-back-to-top>
</div>
<p class="status" id="mv-btt-status" aria-live="polite"></p>
</div>
<div class="col">
<div class="cap">
<span><b>Activity feed</b> · lifts above the chat launcher</span>
<label class="mv-choice" data-control="end" style="font-size:.78rem">
<input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="mv-btt-chat-toggle" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Chat launcher</span></span>
</label>
</div>
<div class="shell">
<div class="pane" id="mv-btt-feed" tabindex="0" aria-label="Recent payments">
<ul class="feed" id="mv-btt-feed-list"></ul>
</div>
<button class="chat" id="mv-btt-chat" type="button" aria-label="Chat with support" data-back-to-top-avoid>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-5A8 8 0 1 1 21 12z"/></svg>
</button>
<mv-back-to-top for="#mv-btt-feed" after="1"></mv-back-to-top>
</div>
</div>
<script type="module">
const root = document.getElementById("mv-btt-demo");
const list = root.querySelector("#mv-btt-feed-list");
const people = [
["Amara Okafor", "Lagos"], ["Kenji Watanabe", "Osaka"], ["Sofía Herrera", "Bogotá"], ["Lukas Brandt", "Hamburg"],
["Priya Raman", "Chennai"], ["Chloé Martin", "Lyon"], ["Mateus Costa", "Porto"], ["Hana Kim", "Seoul"],
["Omar Haddad", "Amman"], ["Ingrid Nilsson", "Malmö"], ["Diego Alvarez", "Austin"], ["Mei Lin", "Taipei"],
["Tariq Hassan", "Dubai"], ["Elena Petrova", "Sofia"], ["Noah Williams", "Toronto"], ["Aisha Bello", "Accra"],
["Rafael Mendes", "Recife"], ["Yuki Sato", "Sapporo"], ["Zara Ahmed", "Leeds"], ["Liam O'Connor", "Cork"],
];
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const when = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
const start = Date.UTC(2026, 8, 24, 18, 30);
people.forEach(([name, city], i) => {
const li = document.createElement("li");
const av = Object.assign(document.createElement("span"), { className: "av", textContent: name.split(" ").map((w) => w[0]).join("") });
av.setAttribute("aria-hidden", "true");
const who = document.createElement("span");
who.append(Object.assign(document.createElement("b"), { textContent: name }));
who.append(Object.assign(document.createElement("small"), { textContent: `${city} · ${when.format(start - i * 47 * 60000)}` }));
const amt = Object.assign(document.createElement("span"), { className: "amt", textContent: money.format(19 + ((i * 37) % 180) + 0.99) });
li.append(av, who, amt);
list.append(li);
});
const chat = root.querySelector("#mv-btt-chat");
root.querySelector("#mv-btt-chat-toggle").addEventListener("change", (e) => { chat.hidden = !e.target.checked; });
const status = root.querySelector("#mv-btt-status");
root.addEventListener("mv-back-to-top", (e) => {
const t = e.detail.target;
const name = t.getAttribute("aria-label") ?? t.textContent.trim();
requestAnimationFrame(() => { status.textContent = `Focus moved to “${name}”. The next Tab continues from the top.`; });
});
// Start part-way down and nudge up, as a reader looking for the way back.
for (const [id, at] of [["mv-btt-docs", 0.72], ["mv-btt-feed", 0.8]]) {
const pane = root.querySelector(`#${id}`);
const y = Math.round((pane.scrollHeight - pane.clientHeight) * at);
pane.scrollTop = y;
requestAnimationFrame(() => requestAnimationFrame(() => { pane.scrollTop = y - 60; }));
}
</script>
</div>API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
after | number | 1.5 | Distance scrolled, in viewport (or pane) heights, before the button may appear. |
for | CSS selector | Scroll container to track and scroll back up. Default: the nearest scrolling ancestor, else the page. Placed outside that container, the button sits in the nearest positioned ancestor. | |
target | CSS selector | <body> (or the pane) | Where keyboard focus goes after the jump, e.g. #main or the page title. A non-focusable target gets a temporary tabindex="-1", removed when focus leaves. |
progress | boolean | Draws a ring around the icon button showing how far down the page the reader is. | |
label | string | Back to top | Accessible name of the icon button (overrides strings.label). |
data-visible | boolean | Set by the component while the button is shown (for styling). | |
data-scope | page | inside | beside | Set by the component: fixed to the viewport, sticky at the bottom of the pane it sits in, or absolute beside the pane it tracks. |
Properties
| Name | Type | Description |
|---|---|---|
strings | { label } | Default texts, for translation (the label attribute wins). Assignable before the element is defined. |
visible | boolean | True while the button is shown (read-only). |
Methods
| Name | Description |
|---|---|
scrollToTop() | Scrolls to the top and moves focus to the target, exactly like a click. |
Events
| Name | Description |
|---|---|
mv-back-to-top | Before scrolling; cancelable (preventDefault to handle it yourself). detail: { target, scroller }. |
Content structure
| Name | Description |
|---|---|
(child) | Optional: your own <a href="#top"> or <button>, kept as the trigger. A link still works without JavaScript; with text inside it becomes a pill with that label. Empty: an arrow icon button is generated. |
CSS classes
| Name | Description |
|---|---|
mv-back-to-top-button / -icon / -ring / -track / -bar | Generated parts (icon and ring are aria-hidden). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-back-to-top-size | var(--mv-control-lg) | Button diameter (height of the pill). |
--mv-back-to-top-inset | var(--mv-space-6) | Distance from the bottom and inline-end edges. |
--mv-back-to-top-bg | var(--mv-surface-raised) | Button background. |
--mv-back-to-top-fg | var(--mv-fg) | Icon and label color. |
--mv-back-to-top-ring | var(--mv-accent) | Progress ring color. |
--mv-back-to-top-track | transparent | Ring track color (the border shows through by default). |
--mv-back-to-top-progress | Scroll progress 0 → 1, written by the component when progress is set (read-only, usable in your own styles). |
Accessibility
A real <button> named “Back to top” (translatable via strings or label). While hidden it has visibility: hidden, so it is out of the tab order and the accessibility tree; it never hides while it has focus or the pointer is on it. Activating it scrolls to the top and moves focus to the target (the page title, #main, <body> or the pane, with a temporary tabindex="-1" when needed and no focus ring on that landing spot), so the next Tab starts at the top instead of at the bottom of the page and screen readers continue reading from there. The scroll is instant under reduced motion (OS setting or data-motion="reduce"), and the appear animation collapses to a fade. The progress ring is decorative; the native scrollbar stays the reference. Nothing is announced on appear. Visible focus ring; forced colors use system colors (ButtonText border, Highlight ring). Right-to-left: sits at the inline end. Hidden in print. Without JavaScript, a child <a href="#top"> stays a working, always-visible link.