UIPackage
Menu

Back Top

back-top ui
Edit on GitHub

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

$ npx 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

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