Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Implementing a Back to Top Component in Vue 3

Tech 2

API Reference

Props

Property Descripiton Type Default
icon Custom icon VNode | Slot undefined
desrciption Text description string | Slot undefined
tooltip Tooltip content string | Slot undefined
tooltipProps Tooltip configuration object {}
type Button style variant 'default' | 'primary' 'default'
shape Button geometry 'circle' | 'square' 'circle'
bottom Distance from bottom (px) number | string 40
right Distance from right (px) number | string 40
zIndex Stacking context level number 9
visibilityHeight Scroll threshold to show button (px) number 180
too Mount container selector string | HTMLElement 'body'
listenTo Scroll event source string | HTMLElement undefined

Events

Event Descriptino Signature
click Triggered on button press () => void
show Indicates visibility state change (visible: boolean) => void

Component Implemantation

<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import type { VNode } from 'vue'
import TooltipComponent from './Tooltip.vue'
import { useSlotDetection, useDomObserver, useTheme, usePassiveSupport } from './composables'

interface ComponentProps {
  icon?: VNode
  description?: string
  tooltip?: string
  tooltipProps?: Record<string, any>
  type?: 'default' | 'primary'
  shape?: 'circle' | 'square'
  bottom?: number | string
  right?: number | string
  zIndex?: number
  visibilityHeight?: number
  to?: string | HTMLElement
  listenTo?: string | HTMLElement
}

const props = withDefaults(defineProps<ComponentProps>(), {
  type: 'default',
  shape: 'circle',
  bottom: 40,
  right: 40,
  zIndex: 9,
  visibilityHeight: 180,
  to: 'body'
})

const emit = defineEmits(['click', 'show'])
const isRendered = ref(false)
const placeholderRef = ref<HTMLElement | null>(null)
const currentScrollY = ref(0)
const scrollSource = ref<HTMLElement | null>(null)
const themeColors = useTheme('BackToTop')
const supportsPassive = usePassiveSupport()
const slotCheck = useSlotDetection(['tooltip', 'icon', 'description'])

const positionStyles = computed(() => ({
  bottom: typeof props.bottom === 'number' ? `${props.bottom}px` : props.bottom,
  right: typeof props.right === 'number' ? `${props.right}px` : props.right
}))

const shouldDisplay = computed(() => currentScrollY.value >= props.visibilityHeight)
const hasTooltip = computed(() => slotCheck.tooltip || props.tooltip)
const hasDesc = computed(() => slotCheck.description || props.description)

watch(shouldDisplay, (visible) => {
  if (visible && !isRendered.value) isRendered.value = true
  emit('show', visible)
}, { immediate: true })

let scrollHandler: (e: Event) => void
let domObserver: ReturnType<typeof useDomObserver>

onMounted(initScrollListener)
onBeforeUnmount(removeScrollListener)

function initScrollListener() {
  removeScrollListener()
  
  if (props.listenTo) {
    scrollSource.value = typeof props.listenTo === 'string' 
      ? document.querySelector(props.listenTo) as HTMLElement 
      : props.listenTo
  } else {
    scrollSource.value = findScrollableAncestor(placeholderRef.value)
  }
  
  if (!scrollSource.value) return
  
  scrollHandler = (e: Event) => {
    currentScrollY.value = (e.target as HTMLElement).scrollTop
  }
  
  scrollSource.value.addEventListener(
    'scroll', 
    scrollHandler, 
    supportsPassive.value ? { passive: true } : false
  )
  
  domObserver = useDomObserver(scrollSource, () => {
    currentScrollY.value = scrollSource.value?.scrollTop || 0
  })
}

function removeScrollListener() {
  if (scrollSource.value && scrollHandler) {
    scrollSource.value.removeEventListener('scroll', scrollHandler)
  }
  domObserver?.disconnect()
}

function findScrollableAncestor(el: HTMLElement | null): HTMLElement | null {
  if (!el || el === document.documentElement) return document.documentElement
  
  const parent = el.parentElement
  if (!parent) return null
  
  const style = window.getComputedStyle(parent)
  const isScrollable = /(auto|scroll)/.test(style.overflow + style.overflowY)
  
  return isScrollable ? parent : findScrollableAncestor(parent)
}

function scrollToTop() {
  if (scrollSource.value) {
    scrollSource.value.scrollTo({ top: 0, behavior: 'smooth' })
    emit('click')
  }
}
</script>

<template>
  <div ref="placeholderRef" style="display: none" />
  <Teleport :to="to">
    <Transition name="fade-scale">
      <div
        v-if="isRendered"
        v-show="shouldDisplay"
        class="back-to-top"
        :style="[positionStyles, { zIndex: props.zIndex }]"
        @click="scrollToTop"
      >
        <TooltipComponent v-if="hasTooltip" :content="tooltip" v-bind="tooltipProps">
          <div class="button-content" :class="[props.type, props.shape]">
            <slot name="icon">
              <svg v-if="!icon" viewBox="0 0 24 24" width="1em" height="1em">
                <!-- SVG path data -->
              </svg>
              <component v-else :is="icon" />
            </slot>
            <span v-if="hasDesc" class="desc">
              <slot name="description">{{ description }}</slot>
            </span>
          </div>
        </TooltipComponent>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.fade-scale-enter-active, .fade-scale-leave-active {
  transition: all 0.3s ease;
}
.fade-scale-enter-from, .fade-scale-leave-to {
  opacity: 0;
  transform: scale(0.8);
}
.back-to-top {
  position: fixed;
}
.button-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
/* Additional styling */
</style>

Usage Examples

<script setup lang="ts">
import { ref } from 'vue'
import BackToTop from './BackToTop.vue'
import { createIcon } from './icons'

const scrollArea = ref<HTMLElement | null>(null)
const customStyles = {
  '--btn-size': '50px',
  '--icon-size': '28px',
  '--primary-color': '#c2410c'
}

function handleVisibilityChange(visible: boolean) {
  console.log('Button visibility:', visible)
}
</script>

<template>
  <div>
    <BackToTop @show="handleVisibilityChange" />
    
    <BackToTop :icon="createIcon('arrow-up')" :right="120" />
    
    <BackToTop 
      type="primary" 
      shape="square"
      description="Top"
      :bottom="100"
    />
    
    <BackToTop 
      :tooltip="'Return to top'"
      :tooltip-props="{ delay: 300 }"
      :right="200"
    />
    
    <BackToTop 
      :style="customStyles"
      :visibility-height="400"
      :listen-to="scrollArea"
    />
    
    <div ref="scrollArea" style="height: 200px; overflow: auto">
      <!-- Scrollable content -->
    </div>
  </div>
</template>

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.