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 Vue ->

Installation

$ npx shadcn@latest add https://uipkge.dev/r/react/back-top.json
Named registry: npx shadcn@latest add @uipkge-react/back-top Installs to: components/ui/back-top/

Examples

Props

Name Type / Values Default Required
size
'sm''default''lg'
default optional
position
'bottom-right''bottom-left''top-right''top-left'
bottom-right optional
threshold

Visibility threshold in pixels. Button appears once scroll passes it.

number optional
target

Target container. Defaults to the window. Pass a CSS selector or an HTMLElement.

string | HTMLElement | Window optional
behavior

Scroll behavior: 'smooth' or 'auto' (instant).

ScrollBehavior 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
icon

Override the default arrow icon.

React.ReactNode optional
onVisible

Fired whenever visibility toggles.

(visible: boolean) => void optional

Files installed (3)

  • components/ui/back-top/BackTop.tsx 5.6 kB
    import * as React from 'react'
    import { ArrowUp } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { backTopVariants, type BackTopVariants } from './back-top.variants'
    
    export interface BackTopProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'content'> {
      /** 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?: BackTopVariants['size']
      /** Edge anchor position. */
      position?: BackTopVariants['position']
      /** 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
      /** Override the default arrow icon. */
      icon?: React.ReactNode
      /** Fired whenever visibility toggles. */
      onVisible?: (visible: boolean) => void
    }
    
    type ScrollTarget = HTMLElement | Window | null
    
    const BackTop = React.forwardRef<HTMLButtonElement, BackTopProps>(
      (
        {
          className,
          threshold = 200,
          target,
          behavior = 'smooth',
          size = 'default',
          position = 'bottom-right',
          offset = 24,
          absolute = false,
          ariaLabel = 'Scroll to top',
          icon,
          onVisible,
          onClick,
          style,
          ...props
        },
        ref,
      ) => {
        const [visible, setVisible] = React.useState(false)
        // Keep the button mounted during the exit animation, then unmount.
        const [mounted, setMounted] = React.useState(false)
        const [dataState, setDataState] = React.useState<'open' | 'closed'>('closed')
        const currentTargetRef = React.useRef<ScrollTarget>(null)
        const onVisibleRef = React.useRef(onVisible)
        onVisibleRef.current = onVisible
    
        function resolveTarget(): ScrollTarget {
          if (typeof window === 'undefined') return null
          if (target === undefined || target === null) return window
          if (typeof target === 'string') {
            const el = document.querySelector<HTMLElement>(target)
            return el ?? window
          }
          return 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 })
          } else {
            ;(el as HTMLElement).scrollTo({ top: 0, behavior })
          }
        }
    
        const handleScroll = React.useCallback(() => {
          const current = currentTargetRef.current
          if (!current) return
          const scrollTop = getScrollTop(current)
          const next = scrollTop > threshold
          setVisible((prev) => {
            if (next !== prev) {
              onVisibleRef.current?.(next)
              return next
            }
            return prev
          })
        }, [threshold])
    
        const handleClick = React.useCallback(
          (e: React.MouseEvent<HTMLButtonElement>) => {
            onClick?.(e)
            const current = currentTargetRef.current
            if (current) scrollToTop(current)
          },
          [onClick, behavior],
        )
    
        // Mount/unmount with exit animation, mirroring the Vue <Transition name="back-top">.
        React.useEffect(() => {
          if (visible) {
            setMounted(true)
            setDataState('open')
            return
          }
          if (mounted) {
            setDataState('closed')
            const t = window.setTimeout(() => setMounted(false), 200)
            return () => window.clearTimeout(t)
          }
          // eslint-disable-next-line react-hooks/exhaustive-deps
        }, [visible])
    
        // Attach scroll listeners; re-resolve when the target prop changes.
        React.useEffect(() => {
          currentTargetRef.current = resolveTarget()
          const current = currentTargetRef.current
          if (!current) return
          current.addEventListener('scroll', handleScroll, { passive: true })
          // If target is a container, also listen to window scroll for safety on resize.
          if (current !== window) {
            window.addEventListener('scroll', handleScroll, { passive: true })
          }
          handleScroll()
          return () => {
            current.removeEventListener('scroll', handleScroll)
            if (current !== window) {
              window.removeEventListener('scroll', handleScroll)
            }
          }
          // eslint-disable-next-line react-hooks/exhaustive-deps
        }, [target, handleScroll])
    
        const positionStyle = React.useMemo<React.CSSProperties>(() => {
          const o = `${offset}px`
          switch (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 }
          }
        }, [position, offset])
    
        if (!mounted) return null
    
        return (
          <button
            ref={ref}
            data-uipkge=""
            data-slot="back-top"
            data-state={dataState}
            data-size={size ?? undefined}
            data-position={position ?? undefined}
            type="button"
            aria-label={ariaLabel}
            className={cn(backTopVariants({ size, position }), absolute ? 'absolute' : 'fixed', className)}
            style={{ ...positionStyle, ...style }}
            onClick={handleClick}
            {...props}
          >
            {icon ?? <ArrowUp aria-hidden="true" />}
          </button>
        )
      },
    )
    BackTop.displayName = 'BackTop'
    
    export { BackTop }
  • 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.tsx` can `import { backTopVariants } from
     * './back-top.variants'` without creating a circular dependency through the
     * index. See card.variants.ts for the same pattern.
     */
    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>
  • components/ui/back-top/index.ts 0.1 kB
    export { BackTop, type BackTopProps } from './BackTop'
    export { backTopVariants, type BackTopVariants } from './back-top.variants'

Raw manifest: https://uipkge.dev/r/react/back-top.json