ScrollTop

Return to the top of a page or scroll container after passing a threshold.

Scroll inside the panel. The back-to-top button appears after 120px.

Set a clear action hierarchy

A region usually needs one primary action. Keep secondary actions near their content and move less common actions into a menu, keeping the usual path clear.

Provide visible feedback

Saving, uploading, and syncing all need feedback. Keep content visible while waiting and show the result when finished. Preserve input after a failure so users can retry.

Make long content easy to browse

Use headings for structure and spacing to separate paragraphs. Keep long lists at a stable scroll position and offer a way back to the beginning without forcing a scroll when content changes.

Support different input methods

Mouse, touch, and keyboard users should all be able to act. Keep focus visible, name icon buttons, and restore focus to a useful place when an overlay closes.

Reduce unnecessary motion

Motion explains changes in state and position. When reduced motion is enabled, keep the outcome while allowing users to skip scrolling and movement animations.

Check realistic scenarios

Try longer labels, narrow the window, use the keyboard, and check dark mode. A component is ready when these scenarios work as well as the basic example.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Card, Heading, ScrollArea, ScrollTop, Stack, Text } from '@hina-ui/vue'

  const area = ref<InstanceType<typeof ScrollArea>>()
  const sections = [
    {
      title: 'Set a clear action hierarchy',
      body: 'A region usually needs one primary action. Keep secondary actions near their content and move less common actions into a menu, keeping the usual path clear.',
    },
    {
      title: 'Provide visible feedback',
      body: 'Saving, uploading, and syncing all need feedback. Keep content visible while waiting and show the result when finished. Preserve input after a failure so users can retry.',
    },
    {
      title: 'Make long content easy to browse',
      body: 'Use headings for structure and spacing to separate paragraphs. Keep long lists at a stable scroll position and offer a way back to the beginning without forcing a scroll when content changes.',
    },
    {
      title: 'Support different input methods',
      body: 'Mouse, touch, and keyboard users should all be able to act. Keep focus visible, name icon buttons, and restore focus to a useful place when an overlay closes.',
    },
    {
      title: 'Reduce unnecessary motion',
      body: 'Motion explains changes in state and position. When reduced motion is enabled, keep the outcome while allowing users to skip scrolling and movement animations.',
    },
    {
      title: 'Check realistic scenarios',
      body: 'Try longer labels, narrow the window, use the keyboard, and check dark mode. A component is ready when these scenarios work as well as the basic example.',
    },
  ]
</script>

<template>
  <Stack class="w-full max-w-lg" gap="sm">
    <Text size="sm" tone="muted">
      Scroll inside the panel. The back-to-top button appears after 120px.
    </Text>
    <Card :padded="false" class="relative overflow-hidden">
      <ScrollArea
        ref="area"
        class="h-72"
        :shadow="false"
        focusable
        label="Interaction design notes"
      >
        <Stack gap="lg" class="p-5 pb-24">
          <Stack v-for="section in sections" :key="section.title" gap="sm">
            <Heading :level="3" size="base">{{ section.title }}</Heading>
            <Text size="sm" tone="muted">{{ section.body }}</Text>
          </Stack>
        </Stack>
      </ScrollArea>
      <ScrollTop :target="() => area?.viewport" :threshold="120" position="absolute" :offset="16" />
    </Card>
  </Stack>
</template>
vue

Usage

import { ScrollTop } from '@hina-ui/vue'
ts

Without target, the component listens to window and appears after scrolling more than 300px. Activation resets only the vertical position, preserving horizontal scroll.

<ScrollTop />
vue

When an AppShell, ScrollArea, or Dialog content viewport handles scrolling, pass its exposed viewport. Scroll target and button placement are independent: target controls which element scrolls, while position controls where the button sits.

<Card class="relative" :padded="false">
  <ScrollArea ref="area" class="h-72">…</ScrollArea>
  <ScrollTop :target="() => area?.viewport" position="absolute" />
</Card>
vue

For AppShell, use () => shell?.mainViewport. With default fixed positioning, mount the button near the outside of your shell. For a local panel, place it outside the scroll area but inside the positioned container so it does not scroll away with the content.

Examples

Clearing bottom content

When the page has a fixed footer or toolbar at the bottom, raise the button with the y of offset; the horizontal spacing keeps its default. A string accepts any CSS length.

<ScrollTop :offset="{ y: 96 }" />
<ScrollTop :offset="{ y: 'calc(var(--footer-height) + 1rem)' }" />
vue

Appearance, scrolling, and focus

Reuse FloatButton sizes, shapes, visible labels, and positioning. Scrolling is smooth by default; behavior="instant" jumps immediately. Reduced-motion preferences always force instant scrolling.

The button remains mounted while a keyboard user still has focus on it, then hides after focus leaves. Alternatively, use focus-target to move focus to a heading at the top. The destination must be focusable, for example a heading with tabindex="-1".

Release checklist

Verify core flows

Check empty states

Check loading and retries

Check keyboard navigation

Check form errors

Check mobile layouts

Check dark mode

Check RTL

Check server rendering

Review documentation examples

Update the changelog

Confirm the release version

Returning to the top focuses the heading, so keyboard navigation can continue from there.

<script setup lang="ts">
  import { ref } from 'vue'
  import { ChevronsUp } from '@lucide/vue'
  import {
    Button,
    Card,
    FormField,
    Heading,
    ScrollArea,
    ScrollTop,
    Stack,
    Switch,
    Text,
  } from '@hina-ui/vue'

  const area = ref<InstanceType<typeof ScrollArea>>()
  const heading = ref<InstanceType<typeof Heading>>()
  const instant = ref(false)
  function goDown() {
    area.value?.viewport?.scrollTo({ top: 450, behavior: 'instant' })
  }
</script>

<template>
  <Stack class="w-full max-w-md" gap="sm">
    <FormField label="Return instantly" orientation="horizontal">
      <Switch v-model="instant" />
    </FormField>
    <Button variant="outline" tone="neutral" class="self-start" @click="goDown">
      Scroll to the middle
    </Button>
    <Card :padded="false" class="relative overflow-hidden">
      <ScrollArea ref="area" class="h-64" :shadow="false" focusable label="Release checklist">
        <Stack class="p-5 pb-24" gap="lg">
          <Heading
            ref="heading"
            :level="3"
            size="base"
            tabindex="-1"
            class="hn-focus-ring rounded-sm"
          >
            Release checklist
          </Heading>
          <Text
            v-for="item in [
              'Verify core flows',
              'Check empty states',
              'Check loading and retries',
              'Check keyboard navigation',
              'Check form errors',
              'Check mobile layouts',
              'Check dark mode',
              'Check RTL',
              'Check server rendering',
              'Review documentation examples',
              'Update the changelog',
              'Confirm the release version',
            ]"
            :key="item"
            size="sm"
            class="border-line border-b pb-3"
          >
            {{ item }}
          </Text>
        </Stack>
      </ScrollArea>
      <ScrollTop
        :target="() => area?.viewport"
        :focus-target="() => heading?.$el"
        :threshold="80"
        :behavior="instant ? 'instant' : 'smooth'"
        position="absolute"
        :offset="16"
        extended
        size="sm"
        shape="square"
        variant="soft"
        tone="accent"
        label="Back to the checklist"
      >
        <ChevronsUp />
      </ScrollTop>
    </Card>
    <Text size="sm" tone="muted">
      Returning to the top focuses the heading, so keyboard navigation can continue from there.
    </Text>
  </Stack>
</template>
vue

A target that mounts later

A getter follows ScrollArea initialization, unmounting, and replacement. An empty getter result hides the button without falling back to page scrolling. Changing the target removes the old listener and immediately checks the new target’s scroll position.

Review log 1 · Review interactions and visual details

Review log 2 · Review interactions and visual details

Review log 3 · Review interactions and visual details

Review log 4 · Review interactions and visual details

Review log 5 · Review interactions and visual details

Review log 6 · Review interactions and visual details

Review log 7 · Review interactions and visual details

Review log 8 · Review interactions and visual details

Review log 9 · Review interactions and visual details

Review log 10 · Review interactions and visual details

Review log 11 · Review interactions and visual details

Review log 12 · Review interactions and visual details

Review log 13 · Review interactions and visual details

Review log 14 · Review interactions and visual details

Review log 15 · Review interactions and visual details

Review log 16 · Review interactions and visual details

Review log 17 · Review interactions and visual details

Review log 18 · Review interactions and visual details

Review log 19 · Review interactions and visual details

Review log 20 · Review interactions and visual details

ScrollTop stays mounted. When its target is unavailable, the button hides without falling back to page scrolling.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Card, FormField, ScrollArea, ScrollTop, Stack, Switch, Text } from '@hina-ui/vue'

  const area = ref<InstanceType<typeof ScrollArea>>()
  const show = ref(true)
</script>

<template>
  <Stack class="w-full max-w-md" gap="sm">
    <FormField label="Mount the scroll area" orientation="horizontal">
      <Switch v-model="show" />
    </FormField>
    <Button
      variant="outline"
      tone="neutral"
      class="self-start"
      :disabled="!show"
      @click="area?.viewport?.scrollTo({ top: 500, behavior: 'instant' })"
    >
      Scroll to the middle
    </Button>
    <Card :padded="false" class="relative h-64 overflow-hidden">
      <ScrollArea
        v-if="show"
        ref="area"
        class="h-full"
        :shadow="false"
        focusable
        label="Review log"
      >
        <Stack class="p-5 pb-24" gap="lg">
          <Text v-for="i in 20" :key="i" size="sm" class="border-line border-b pb-3">
            Review log {{ i }} · Review interactions and visual details
          </Text>
        </Stack>
      </ScrollArea>
      <Text v-else size="sm" tone="muted" class="p-5">The scroll area is unmounted</Text>
      <ScrollTop :target="() => area?.viewport" position="absolute" :threshold="120" :offset="16" />
    </Card>
    <Text size="sm" tone="muted">
      ScrollTop stays mounted. When its target is unavailable, the button hides without falling back
      to page scrolling.
    </Text>
  </Stack>
</template>
vue

Behavior

  • Uses a passive scroll listener on its target without polling every frame or scanning content.
  • Does not read target getters or render a visible button on the server; it measures scroll position after mounting.
  • Appears when the vertical scroll position is strictly greater than threshold. Threshold changes take effect reactively.
  • loading and disabled block scrolling through both clicks and exposed methods.
  • @click.prevent cancels the default action. click reports activation, not completion of smooth scrolling.

API

Props

Prop
Type
Default
Description
target
HTMLElement | Window | null | (() => HTMLElement | Window | null | undefined)
window
Scroll target; a getter supports delayed initialization
threshold
number
300
Vertical display threshold in pixels
behavior
'smooth' | 'instant' | 'auto'
'smooth'
Scroll behavior; auto follows the target’s CSS scrolling behavior
focusTarget
HTMLElement | (() => HTMLElement | null | undefined)
—
Focus destination on activation, using preventScroll to avoid an extra jump
label
string
Localized “Back to top”
Button name
variant
'solid' | 'soft' | 'outline'
'outline'
Button appearance
tone
'accent' | 'neutral' | 'danger'
'neutral'
Button tone

Also accepts FloatButton’s position, placement, offset, size, shape, extended, tooltip, tooltipSide, loading, disabled, ripple, class, and style, with the same defaults. Scroll state controls visibility; there is no visible prop.

Slots

SlotDefault
Description
defaultUp arrow
Custom button icon

Events

Event
Payload
Description
click
MouseEvent
Button activation; prevent default to cancel scrolling

Expose

Name
Type
Description
visible
boolean
Current visibility, read-only
scrollToTop
() => void
Scroll to the target’s top using the current configuration