Buttons

Back To Top Button

A back-to-top button with a progress ring: the scroll handler fills the ring and, when asked, hides the button until a threshold is passed.

  • button
  • scroll
  • progress
  • navigation

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/button-046?lang=en

Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "button-046" (Back To Top Button) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/button-046.json

Registry item: https://vibeui.ru/r/button-046.json
Installs to: components/vibeui/button-046.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A back-to-top button with a progress ring: the scroll handler fills the ring and, when asked, hides the button until a threshold is passed.

A round back-to-top button that also shows how much of the page is behind you: the progress ring is a conic-gradient driven by the scroll fraction. With autoHide it appears only past a threshold and drops out of the tab order while hidden. The scroll is smooth, or instant when motion is reduced. Zero dependencies, one file.

## 3. How to use it
import { Button046 } from "@/components/vibeui/button-046"

<Button046 autoHide threshold={320} label="Back to top" />

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- removing the listeners in the useEffect cleanup — otherwise they pile up on every mount
- passive:true on the scroll listener: without it the handler slows the scroll itself
- aria-hidden and tabIndex -1 while hidden: an invisible button must not catch Tab
- the prefers-reduced-motion check before smooth scrolling — this is the one place CSS alone cannot switch motion off
- the progress ring as a conic-gradient driven by a variable, not a separate element on top
- the light middle and the border: the button has to read over any page content

## 6. You may change
- the button's name through the label prop, which becomes its aria-label
- the appearance threshold through the threshold prop, in pixels
- hiding before the threshold through the autoHide prop
- the ring colour through the accent prop
- positioning through className: the component sets no position of its own

## 7. Rules
- The component does not position itself: fixed or sticky is your app's job, otherwise the button stays in the flow.
- Do not enable autoHide where the page is shorter than the viewport: the button will never appear.
- Do not drop the prefers-reduced-motion check: smooth scrolling makes some people motion-sick.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/button-046.json
https://vibeui.ru/r/button-046.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-button-046-*. Клиентский компонент: слушатели scroll и resize висят на window, scroll повешен с passive:true, оба снимаются при размонтировании. Доля прокрутки уходит в переменную --vibeui-button-046-progress и рисует кольцо conic-gradient. Прячется кнопка только при autoHide; в скрытом виде она получает pointer-events:none, aria-hidden и tabIndex -1, поэтому не ловит Tab. Прокрутка наверх идёт behavior:smooth, а при prefers-reduced-motion — мгновенно.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useEffect, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Button046Props = Omit<
  ComponentPropsWithoutRef<"button">,
  "children"
> & {
  label?: string
  /** Прокрутка в пикселях, после которой кнопка нужна. */
  threshold?: number
  /** Прятать кнопку до порога. По умолчанию она видна всегда. */
  autoHide?: boolean
  accent?: string
}

// Идея компонента: кнопка «наверх» знает, сколько страницы позади. Кольцо
// прогресса — conic-gradient по переменной, которую обновляет обработчик
// прокрутки; та же величина решает, показывать ли кнопку. Слушатель повешен
// с passive:true, чтобы не тормозить прокрутку, и снимается при размонтировании.
const STYLES = `
:where([data-vibeui-block="button-046"]){
--vibeui-button-046-progress:0;
--vibeui-button-046-surface:oklch(1 0 0);
--vibeui-button-046-border:oklch(0.9 0.006 265);
--vibeui-button-046-fg:oklch(0.26 0.02 265);
--vibeui-button-046-accent:oklch(0.55 0.17 265);
--vibeui-button-046-size:3rem;
--vibeui-button-046-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="button-046"]{
position:relative;appearance:none;cursor:pointer;box-sizing:border-box;
display:inline-flex;align-items:center;justify-content:center;
width:var(--vibeui-button-046-size);height:var(--vibeui-button-046-size);
padding:3px;border:0;border-radius:50%;
/* Кольцо прогресса и бумажная середина в одном фоне. */
background:
conic-gradient(var(--vibeui-button-046-accent) calc(var(--vibeui-button-046-progress) * 1%),oklch(0.9 0.006 265) 0) border-box;
color:var(--vibeui-button-046-fg);
font-family:var(--vibeui-button-046-font);
box-shadow:0 10px 24px -16px oklch(0 0 0 / 60%);
transition:opacity .2s ease,transform .2s ease;
}
[data-vibeui-block="button-046"][data-visible="false"]{
opacity:0;transform:translateY(0.5rem) scale(.9);pointer-events:none;
}
[data-vibeui-block="button-046"] [data-part="face"]{
display:flex;align-items:center;justify-content:center;
width:100%;height:100%;border-radius:50%;
background:var(--vibeui-button-046-surface);
border:1px solid var(--vibeui-button-046-border);
transition:background-color .16s ease;
}
[data-vibeui-block="button-046"]:hover [data-part="face"]{
background:color-mix(in oklab,var(--vibeui-button-046-accent) 8%,var(--vibeui-button-046-surface));
}
[data-vibeui-block="button-046"]:focus-visible{outline:2px solid var(--vibeui-button-046-accent);outline-offset:3px}
[data-vibeui-block="button-046"] [data-part="arrow"]{position:relative;width:1rem;height:1rem}
[data-vibeui-block="button-046"] [data-part="arrow"]::before{
content:"";position:absolute;left:50%;bottom:0;width:1.75px;height:0.875rem;
margin-left:-0.875px;background:currentColor;border-radius:1px;
}
[data-vibeui-block="button-046"] [data-part="arrow"]::after{
content:"";position:absolute;left:50%;top:0.125rem;width:0.5rem;height:0.5rem;
margin-left:-0.25rem;box-sizing:border-box;
border:1.75px solid currentColor;border-right:0;border-bottom:0;
transform:rotate(45deg);
}
[data-vibeui-block="button-046"] [data-part="arrow"]{transition:transform .18s cubic-bezier(0.16,1,0.3,1)}
[data-vibeui-block="button-046"]:hover [data-part="arrow"]{transform:translateY(-2px)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="button-046"] *{animation:none!important;transition:none!important}}
`

/**
 * Кнопка «наверх» с кольцом прочитанного и появлением по прокрутке.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Button046({
  label = "Наверх",
  threshold = 320,
  autoHide = false,
  accent,
  type = "button",
  className,
  style,
  ...props
}: Button046Props) {
  const [offset, setOffset] = useState(0)
  const [progress, setProgress] = useState(0)

  useEffect(() => {
    const read = () => {
      const top = window.scrollY
      const total =
        document.documentElement.scrollHeight - window.innerHeight || 1

      setOffset(top)
      setProgress(Math.min(100, Math.max(0, (top / total) * 100)))
    }

    read()
    window.addEventListener("scroll", read, { passive: true })
    window.addEventListener("resize", read)

    return () => {
      window.removeEventListener("scroll", read)
      window.removeEventListener("resize", read)
    }
  }, [])

  const visible = !autoHide || offset >= threshold

  const palette = {
    "--vibeui-button-046-progress": progress.toFixed(1),
    ...(accent ? { "--vibeui-button-046-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-button-046" precedence="medium">
        {STYLES}
      </style>
      <button
        {...props}
        type={type}
        data-vibeui-block="button-046"
        data-visible={String(visible)}
        className={className}
        style={palette}
        aria-label={label}
        aria-hidden={visible ? undefined : true}
        tabIndex={visible ? undefined : -1}
        onClick={() => {
          const reduced = window.matchMedia(
            "(prefers-reduced-motion: reduce)",
          ).matches

          window.scrollTo({ top: 0, behavior: reduced ? "auto" : "smooth" })
        }}
      >
        <span data-part="face">
          <span data-part="arrow" aria-hidden="true" />
        </span>
      </button>
    </>
  )
}