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>
Usage
import { ScrollTop } from '@hina-ui/vue'
Without target, the component listens to window and appears after scrolling more than 300px. Activation resets only the vertical position, preserving horizontal scroll.
<ScrollTop />
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>
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)' }" />
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>
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>
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. loadinganddisabledblock scrolling through both clicks and exposed methods.@click.preventcancels the default action.clickreports 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
| Slot | Default | Description |
|---|---|---|
default | Up 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 |