Exclusivobeta

Back to Top <mv-back-to-top>

Botão flutuante de voltar ao topo que só aparece quando o leitor já desceu bastante na página e sobe ou para, nunca enquanto lê para baixo, com um anel de progresso opcional. O que as versões feitas à mão deixam passar: ele leva o foco do teclado para o topo (o próximo Tab começa ali, e não no fim), pula na hora com movimento reduzido, se ergue acima dos botões de chat ou dos banners de cookies e funciona dentro de qualquer painel com rolagem.

CategoriaRolagem
TipoWeb Component (<mv-back-to-top>)
Statusbeta
Keywordsexclusive, 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

Instalação

node scripts/add.mjs back-to-top --out ./src/marvelous

Agente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["back-to-top"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Arquivos copiados (dependências incluídas): 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.

Uso

Início rápido, a menor marcação que funciona:

<mv-back-to-top></mv-back-to-top>

Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:

<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

NameTipoDefaultDescription
afternumber1.5Distance scrolled, in viewport (or pane) heights, before the button may appear.
forCSS selectorScroll 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.
targetCSS 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.
progressbooleanDraws a ring around the icon button showing how far down the page the reader is.
labelstringBack to topAccessible name of the icon button (overrides strings.label).
data-visiblebooleanSet by the component while the button is shown (for styling).
data-scopepage | inside | besideSet 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

NameTipoDescription
strings{ label }Default texts, for translation (the label attribute wins). Assignable before the element is defined.
visiblebooleanTrue while the button is shown (read-only).

Methods

NameDescription
scrollToTop()Scrolls to the top and moves focus to the target, exactly like a click.

Events

NameDescription
mv-back-to-topBefore scrolling; cancelable (preventDefault to handle it yourself). detail: { target, scroller }.

Content structure

NameDescription
(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

NameDescription
mv-back-to-top-button / -icon / -ring / -track / -barGenerated parts (icon and ring are aria-hidden).

CSS variables

NameDefaultDescription
--mv-back-to-top-sizevar(--mv-control-lg)Button diameter (height of the pill).
--mv-back-to-top-insetvar(--mv-space-6)Distance from the bottom and inline-end edges.
--mv-back-to-top-bgvar(--mv-surface-raised)Button background.
--mv-back-to-top-fgvar(--mv-fg)Icon and label color.
--mv-back-to-top-ringvar(--mv-accent)Progress ring color.
--mv-back-to-top-tracktransparentRing track color (the border shows through by default).
--mv-back-to-top-progressScroll 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.

Esta página foi traduzida com IA. Informar um problema de tradução