Back Top
back-top ui Scroll-to-top floating button. Appears after the target container scrolls past a threshold and smooth-scrolls back on click. Supports custom target container, visibility threshold, scroll behavior, custom icon slot, four edge anchors, size variants, and edge offset.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/back-top.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/back-top.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/back-top.json $ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/back-top.json Named registry:
npx shadcn-vue@latest add @uipkge/back-top Installs to: app/components/ui/back-top/ Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
threshold Visibility threshold in pixels. Button appears once scroll passes it. | number | 200 | optional |
target Target container. Defaults to the window. Pass a CSS selector or an HTMLElement. | string | HTMLElement | Window | () => (typeof window !== 'undefined' ? window : undefined… | optional |
behavior Scroll behavior: 'smooth' or 'auto' (instant). | ScrollBehavior | — | optional |
size Size variant. | 'sm''default''lg' | — | optional |
position Edge anchor position. | 'bottom-right''bottom-left''top-right''top-left' | — | optional |
offset Distance from the viewport edge (px). | number | — | optional |
absolute Use absolute positioning (for section-level containers) instead of fixed (viewport). | boolean | — | optional |
ariaLabel Accessible label. | string | — | optional |
npm dependencies
Files installed (3)
-
app/components/ui/back-top/BackTop.vue 4.9 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { ArrowUp } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { backTopVariants } from './back-top.variants' // Inlined union: SFC compiler can't extract runtime props from // `BackTopVariants['size']` / `BackTopVariants['position']`. const props = withDefaults( defineProps<{ class?: HTMLAttributes['class'] /** Visibility threshold in pixels. Button appears once scroll passes it. */ threshold?: number /** Target container. Defaults to the window. Pass a CSS selector or an HTMLElement. */ target?: string | HTMLElement | Window /** Scroll behavior: 'smooth' or 'auto' (instant). */ behavior?: ScrollBehavior /** Size variant. */ size?: 'sm' | 'default' | 'lg' /** Edge anchor position. */ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' /** Distance from the viewport edge (px). */ offset?: number /** Use absolute positioning (for section-level containers) instead of fixed (viewport). */ absolute?: boolean /** Accessible label. */ ariaLabel?: string }>(), { threshold: 200, target: () => (typeof window !== 'undefined' ? window : undefined), behavior: 'smooth', size: 'default', position: 'bottom-right', offset: 24, absolute: false, ariaLabel: 'Scroll to top', }, ) const emit = defineEmits<{ visible: [value: boolean] click: [event: MouseEvent] }>() const visible = ref(false) function resolveTarget(): HTMLElement | Window | null { if (typeof window === 'undefined') return null if (props.target === undefined || props.target === null) return window if (typeof props.target === 'string') { const el = document.querySelector<HTMLElement>(props.target) return el ?? window } return props.target } function getScrollTop(el: HTMLElement | Window): number { if (el === window) { return window.scrollY ?? document.documentElement.scrollTop ?? document.body.scrollTop ?? 0 } return (el as HTMLElement).scrollTop } function scrollToTop(el: HTMLElement | Window) { if (el === window) { window.scrollTo({ top: 0, behavior: props.behavior }) } else { ;(el as HTMLElement).scrollTo({ top: 0, behavior: props.behavior }) } } let currentTarget: HTMLElement | Window | null = null function handleScroll() { if (!currentTarget) return const scrollTop = getScrollTop(currentTarget) const next = scrollTop > props.threshold if (next !== visible.value) { visible.value = next emit('visible', next) } } function handleClick(e: MouseEvent) { emit('click', e) if (currentTarget) scrollToTop(currentTarget) } const positionStyle = computed(() => { const o = `${props.offset}px` switch (props.position) { case 'bottom-left': return { left: o, bottom: o } case 'top-right': return { right: o, top: o } case 'top-left': return { left: o, top: o } default: return { right: o, bottom: o } } }) onMounted(() => { currentTarget = resolveTarget() if (!currentTarget) return const listenEl: HTMLElement | Window = currentTarget listenEl.addEventListener('scroll', handleScroll, { passive: true }) // If target is a container, also listen to window scroll for safety on resize. if (currentTarget !== window) { window.addEventListener('scroll', handleScroll, { passive: true }) } handleScroll() }) onBeforeUnmount(() => { if (currentTarget) { currentTarget.removeEventListener('scroll', handleScroll) if (currentTarget !== window) { window.removeEventListener('scroll', handleScroll) } } }) watch( () => props.target, () => { if (currentTarget) { currentTarget.removeEventListener('scroll', handleScroll) if (currentTarget !== window) { window.removeEventListener('scroll', handleScroll) } } currentTarget = resolveTarget() if (currentTarget) { currentTarget.addEventListener('scroll', handleScroll, { passive: true }) if (currentTarget !== window) { window.addEventListener('scroll', handleScroll, { passive: true }) } } handleScroll() }, ) </script> <template> <Transition name="back-top"> <button v-show="visible" data-uipkge data-slot="back-top" :data-state="visible ? 'open' : 'closed'" :data-size="size" :data-position="position" type="button" :aria-label="ariaLabel" :class="cn(backTopVariants({ size, position }), props.absolute ? 'absolute' : 'fixed', props.class)" :style="positionStyle" @click="handleClick" > <slot name="icon"> <ArrowUp aria-hidden="true" /> </slot> </button> </Transition> </template> <style scoped> .back-top-enter-active, .back-top-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; } .back-top-enter-from, .back-top-leave-to { opacity: 0; transform: scale(0.9); } </style> -
app/components/ui/back-top/back-top.variants.ts 1.5 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so `BackTop.vue` can `import { backTopVariants } from * './back-top.variants'` without creating a circular dependency through the * index. See card.variants.ts for the same pattern + the SSR symptom that * motivated the split. */ export const backTopVariants = cva( 'inline-flex items-center justify-center rounded-full border bg-background text-foreground shadow-lg transition-all duration-200 hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none cursor-pointer motion-safe:data-[state=open]:animate-in motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=closed]:zoom-out-95 [&_svg:not([class*=size-])]:size-5 [&_svg]:pointer-events-none [&_svg]:shrink-0', { variants: { size: { sm: 'size-8 [&_svg:not([class*=size-])]:size-4', default: 'size-10', lg: 'size-12 [&_svg:not([class*=size-])]:size-6', }, position: { 'bottom-right': '', 'bottom-left': '', 'top-right': '', 'top-left': '', }, }, defaultVariants: { size: 'default', position: 'bottom-right', }, }, ) export type BackTopVariants = VariantProps<typeof backTopVariants> -
app/components/ui/back-top/index.ts 0.3 kB
export { default as BackTop } from './BackTop.vue' // Re-export variant API from the sibling file (kept separate to avoid the // BackTop.vue <-> index.ts circular import that broke dev SSR for Card). export { backTopVariants, type BackTopVariants } from './back-top.variants'
Raw manifest: https://uipkge.dev/r/vue/back-top.json