Skip to content

Tooltip Component Code ​

Dependencies

This component requires:

  • Vue 3 with Composition API
  • Vue Teleport for positioning
  • Vue Transition for animations

Full Component Code ​

vue
<template>
  <div 
    class="tooltip-wrapper"
    ref="triggerRef"
  >
    <div
      ref="triggerEl"
      @click="handleTriggerClick"
      @mouseenter="props.trigger === 'hover' ? handleMouseEnter() : null"
      @mouseleave="props.trigger === 'hover' ? handleMouseLeave() : null"
      @focusin="props.trigger === 'focus' ? handleFocus($event) : null"
      @focusout="props.trigger === 'focus' ? handleBlur($event) : null"
    >
      <slot></slot>
    </div>
    
    <!-- Tooltip using Teleport for better positioning -->
    <Teleport to="body">
      <Transition name="tooltip-fade">
        <div
          v-if="isVisible"
          ref="tooltipRef"
          :class="[
            'tooltip',
            `tooltip-${actualPosition}`,
            { 'tooltip-no-arrow': !showArrow },
            customClass
          ]"
          :style="tooltipStyle"
        >
          <div class="tooltip-content">
            <slot name="tooltip">
              {{ content }}
            </slot>
          </div>
          
          <!-- Arrow -->
          <div 
            v-if="showArrow" 
            class="tooltip-arrow"
            :style="arrowPosition"
          ></div>
        </div>
      </Transition>
    </Teleport>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'

const props = defineProps({
  content: {
    type: String,
    default: ''
  },
  position: {
    type: String,
    default: 'top',
    validator: (value) => ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(value)
  },
  trigger: {
    type: String,
    default: 'hover',
    validator: (value) => ['hover', 'click', 'focus'].includes(value)
  },
  showArrow: {
    type: Boolean,
    default: true
  },
  offset: {
    type: Number,
    default: 8
  },
  delay: {
    type: Number,
    default: 300
  },
  hideDelay: {
    type: Number,
    default: 100
  },
  customClass: {
    type: String,
    default: ''
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['show', 'hide', 'toggle'])

// Refs
const triggerRef = ref(null)
const tooltipRef = ref(null)
const triggerEl = ref(null)

// Local state
const isVisible = ref(false)
const showTimeout = ref(null)
const hideTimeout = ref(null)
const tooltipPosition = ref({ top: '0px', left: '0px' })
const actualPosition = ref(props.position)
const arrowPosition = ref({})

// Collision detection helpers
const getViewportBounds = () => {
  return {
    width: window.innerWidth,
    height: window.innerHeight,
    scrollTop: window.pageYOffset || document.documentElement.scrollTop,
    scrollLeft: window.pageXOffset || document.documentElement.scrollLeft,
  }
}

const detectCollisions = (position, tooltipRect) => {
  const viewport = getViewportBounds()
  const buffer = 16 // 1rem buffer
  
  return {
    top: position.top < viewport.scrollTop + buffer,
    bottom: position.top + tooltipRect.height > viewport.scrollTop + viewport.height - buffer,
    left: position.left < viewport.scrollLeft + buffer,
    right: position.left + tooltipRect.width > viewport.scrollLeft + viewport.width - buffer,
  }
}

const getFlippedPosition = (position, collisions) => {
  // Flip vertically
  if ((position === 'top' || position.includes('top')) && collisions.top && !collisions.bottom) {
    return position.replace('top', 'bottom')
  }
  if ((position === 'bottom' || position.includes('bottom')) && collisions.bottom && !collisions.top) {
    return position.replace('bottom', 'top')
  }
  
  // Flip horizontally  
  if (position === 'left' && collisions.left && !collisions.right) {
    return 'right'
  }
  if (position === 'right' && collisions.right && !collisions.left) {
    return 'left'
  }
  
  return position
}

const calculatePosition = () => {
  if (!triggerEl.value || !tooltipRef.value) {
    return { top: '0px', left: '0px' }
  }

  const triggerRect = triggerEl.value.getBoundingClientRect()
  const tooltipRect = tooltipRef.value.getBoundingClientRect()
  const viewport = getViewportBounds()
  
  // Calculate initial position
  let position = calculateInitialPosition(triggerRect, tooltipRect, props.position, viewport)
  
  // Detect collisions
  const collisions = detectCollisions(position, tooltipRect)
  
  // Get flipped position if needed
  const flippedPosition = getFlippedPosition(props.position, collisions)
  actualPosition.value = flippedPosition
  
  // Recalculate with flipped position if it changed
  if (flippedPosition !== props.position) {
    position = calculateInitialPosition(triggerRect, tooltipRect, flippedPosition, viewport)
  }
  
  // Final adjustments to keep within viewport
  position = adjustForViewportBounds(position, tooltipRect, viewport)

  return {
    top: `${position.top}px`,
    left: `${position.left}px`
  }
}

const calculateInitialPosition = (triggerRect, tooltipRect, position, viewport) => {
  let top, left
  const offset = props.offset

  switch (position) {
    case 'top':
      top = triggerRect.top + viewport.scrollTop - tooltipRect.height - offset
      left = triggerRect.left + viewport.scrollLeft + (triggerRect.width - tooltipRect.width) / 2
      break
    case 'bottom':
      top = triggerRect.bottom + viewport.scrollTop + offset
      left = triggerRect.left + viewport.scrollLeft + (triggerRect.width - tooltipRect.width) / 2
      break
    case 'left':
      top = triggerRect.top + viewport.scrollTop + (triggerRect.height - tooltipRect.height) / 2
      left = triggerRect.left + viewport.scrollLeft - tooltipRect.width - offset
      break
    case 'right':
      top = triggerRect.top + viewport.scrollTop + (triggerRect.height - tooltipRect.height) / 2
      left = triggerRect.right + viewport.scrollLeft + offset
      break
    case 'top-left':
      top = triggerRect.top + viewport.scrollTop - tooltipRect.height - offset
      left = triggerRect.left + viewport.scrollLeft
      break
    case 'top-right':
      top = triggerRect.top + viewport.scrollTop - tooltipRect.height - offset
      left = triggerRect.right + viewport.scrollLeft - tooltipRect.width
      break
    case 'bottom-left':
      top = triggerRect.bottom + viewport.scrollTop + offset
      left = triggerRect.left + viewport.scrollLeft
      break
    case 'bottom-right':
      top = triggerRect.bottom + viewport.scrollTop + offset
      left = triggerRect.right + viewport.scrollLeft - tooltipRect.width
      break
  }

  return { top, left }
}

const adjustForViewportBounds = (position, tooltipRect, viewport) => {
  let { top, left } = position
  const buffer = 16 // 1rem buffer
  
  // Adjust horizontal position
  if (left < viewport.scrollLeft + buffer) {
    left = viewport.scrollLeft + buffer
  } else if (left + tooltipRect.width > viewport.scrollLeft + viewport.width - buffer) {
    left = viewport.scrollLeft + viewport.width - tooltipRect.width - buffer
  }
  
  // Adjust vertical position
  if (top < viewport.scrollTop + buffer) {
    top = viewport.scrollTop + buffer
  } else if (top + tooltipRect.height > viewport.scrollTop + viewport.height - buffer) {
    top = viewport.scrollTop + viewport.height - tooltipRect.height - buffer
  }
  
  return { top, left }
}

const calculateArrowPosition = (triggerRect, tooltipRect, actualPos, viewport) => {
  const arrowOffset = 12 // Distance from tooltip edge
  let arrowStyle = {}
  
  // Calculate where the arrow should point relative to trigger center
  const triggerCenterX = triggerRect.left + triggerRect.width / 2
  const triggerCenterY = triggerRect.top + triggerRect.height / 2
  const tooltipLeft = parseFloat(tooltipPosition.value.left.replace('px', ''))
  const tooltipTop = parseFloat(tooltipPosition.value.top.replace('px', ''))
  
  if (actualPos.includes('top') || actualPos.includes('bottom')) {
    // For top/bottom positions, arrow should point to trigger center horizontally
    const arrowLeft = triggerCenterX - tooltipLeft
    const maxArrowLeft = tooltipRect.width - arrowOffset
    const minArrowLeft = arrowOffset
    
    arrowStyle.left = `${Math.max(minArrowLeft, Math.min(maxArrowLeft, arrowLeft))}px`
    arrowStyle.transform = 'translateX(-50%)'
  } else if (actualPos.includes('left') || actualPos.includes('right')) {
    // For left/right positions, arrow should point to trigger center vertically
    const arrowTop = triggerCenterY - tooltipTop
    const maxArrowTop = tooltipRect.height - arrowOffset
    const minArrowTop = arrowOffset
    
    arrowStyle.top = `${Math.max(minArrowTop, Math.min(maxArrowTop, arrowTop))}px`
    arrowStyle.transform = 'translateY(-50%)'
  }
  
  return arrowStyle
}

const updatePosition = () => {
  if (tooltipRef.value && isVisible.value && triggerEl.value) {
    const newPosition = calculatePosition()
    tooltipPosition.value = newPosition
    
    // Update arrow position after tooltip position is set
    nextTick(() => {
      if (tooltipRef.value && triggerEl.value) {
        const triggerRect = triggerEl.value.getBoundingClientRect()
        const tooltipRect = tooltipRef.value.getBoundingClientRect()
        const viewport = getViewportBounds()
        arrowPosition.value = calculateArrowPosition(triggerRect, tooltipRect, actualPosition.value, viewport)
      }
    })
  }
}

// Methods
const show = () => {
  if (props.disabled) return
  
  clearTimeout(hideTimeout.value)
  
  if (props.delay > 0) {
    showTimeout.value = setTimeout(() => {
      isVisible.value = true
      emit('show')
      nextTick(() => {
        updatePosition()
      })
    }, props.delay)
  } else {
    isVisible.value = true
    emit('show')
    nextTick(() => {
      updatePosition()
    })
  }
}

const hide = () => {
  clearTimeout(showTimeout.value)
  
  if (props.hideDelay > 0) {
    hideTimeout.value = setTimeout(() => {
      isVisible.value = false
      emit('hide')
    }, props.hideDelay)
  } else {
    isVisible.value = false
    emit('hide')
  }
}

const toggle = () => {
  if (isVisible.value) {
    hide()
  } else {
    show()
  }
  emit('toggle', isVisible.value)
}

// Event handlers
const handleMouseEnter = () => {
  if (props.trigger === 'hover') {
    show()
  }
}

const handleMouseLeave = () => {
  if (props.trigger === 'hover') {
    hide()
  }
}

const handleTriggerClick = (event) => {
  event.preventDefault()
  event.stopPropagation()
  
  if (props.trigger === 'click') {
    toggle()
  }
}

const handleFocus = (event) => {
  if (props.trigger === 'focus') {
    // Check if the focused element is an input, textarea, select, or has tabindex
    const focusableElements = ['input', 'textarea', 'select', 'button']
    const target = event.target
    
    if (focusableElements.includes(target.tagName.toLowerCase()) || 
        target.hasAttribute('tabindex') || 
        target.contentEditable === 'true') {
      show()
    }
  }
}

const handleBlur = (event) => {
  if (props.trigger === 'focus') {
    hide()
  }
}

// Computed tooltip positioning
const tooltipStyle = computed(() => {
  return {
    position: 'absolute',
    top: tooltipPosition.value.top,
    left: tooltipPosition.value.left,
    zIndex: 1000,
    visibility: isVisible.value ? 'visible' : 'hidden',
  }
})

// Event handlers for resize and scroll
const handleResize = () => {
  if (isVisible.value) {
    updatePosition()
  }
}

const handleScroll = () => {
  if (isVisible.value) {
    updatePosition()
  }
}

// Cleanup on unmount
onUnmounted(() => {
  clearTimeout(showTimeout.value)
  clearTimeout(hideTimeout.value)
})

// Handle click outside for click trigger
const handleClickOutside = (event) => {
  if (props.trigger === 'click' && isVisible.value) {
    if (triggerRef.value && !triggerRef.value.contains(event.target) && 
        tooltipRef.value && !tooltipRef.value.contains(event.target)) {
      hide()
    }
  }
}

onMounted(() => {
  if (props.trigger === 'click') {
    document.addEventListener('click', handleClickOutside)
  }
  window.addEventListener('resize', handleResize)
  window.addEventListener('scroll', handleScroll, true)
})

onUnmounted(() => {
  if (props.trigger === 'click') {
    document.removeEventListener('click', handleClickOutside)
  }
  window.removeEventListener('resize', handleResize)
  window.removeEventListener('scroll', handleScroll, true)
  clearTimeout(showTimeout.value)
  clearTimeout(hideTimeout.value)
})

// Expose methods for programmatic control
defineExpose({
  show,
  hide,
  toggle,
  isVisible: computed(() => isVisible.value)
})
</script>


<style scoped lang="scss">
.tooltip-wrapper {
  position: relative;
  display: inline-block;
}

.tooltip {
  background: #1f2937;
  color: white;
  border-radius: 6px;
  padding: 8px 12px;
  font-size: 14px;
  line-height: 1.4;
  white-space: nowrap;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  pointer-events: none;
  
  .tooltip-content {
    position: relative;
    z-index: 1;
  }
  
  .tooltip-arrow {
    position: absolute;
    width: 0;
    height: 0;
    border-style: solid;
  }
}

// Position-specific arrow styles with dynamic positioning
.tooltip-top .tooltip-arrow {
  top: 100%;
  border-width: 6px 6px 0 6px;
  border-color: #1f2937 transparent transparent transparent;
}

.tooltip-bottom .tooltip-arrow {
  bottom: 100%;
  border-width: 0 6px 6px 6px;
  border-color: transparent transparent #1f2937 transparent;
}

.tooltip-left .tooltip-arrow {
  left: 100%;
  border-width: 6px 0 6px 6px;
  border-color: transparent transparent transparent #1f2937;
}

.tooltip-right .tooltip-arrow {
  right: 100%;
  border-width: 6px 6px 6px 0;
  border-color: transparent #1f2937 transparent transparent;
}

.tooltip-top-left .tooltip-arrow,
.tooltip-top-right .tooltip-arrow {
  top: 100%;
  border-width: 6px 6px 0 6px;
  border-color: #1f2937 transparent transparent transparent;
}

.tooltip-bottom-left .tooltip-arrow,
.tooltip-bottom-right .tooltip-arrow {
  bottom: 100%;
  border-width: 0 6px 6px 6px;
  border-color: transparent transparent #1f2937 transparent;
}

// No arrow variant
.tooltip-no-arrow .tooltip-arrow {
  display: none;
}

// Smooth transition animations
.tooltip-fade-enter-active {
  transition: opacity 0.2s ease-out, transform 0.2s ease-out;
}

.tooltip-fade-leave-active {
  transition: opacity 0.15s ease-in, transform 0.15s ease-in;
}

.tooltip-fade-enter-from {
  opacity: 0;
  transform: translateY(-4px);
}

.tooltip-fade-leave-to {
  opacity: 0;
  transform: translateY(-4px);
}

// Theme variants with proper arrow colors
.tooltip.tooltip-light {
  background: white;
  color: #1f2937;
  border: 1px solid #e5e7eb;
  
  &.tooltip-top .tooltip-arrow {
    border-top-color: white;
  }
  &.tooltip-bottom .tooltip-arrow {
    border-bottom-color: white;
  }
  &.tooltip-left .tooltip-arrow {
    border-left-color: white;
  }
  &.tooltip-right .tooltip-arrow {
    border-right-color: white;
  }
}

// Success, error, warning variants with proper arrow colors
.tooltip.tooltip-success {
  background: #10b981;
  
  &.tooltip-top .tooltip-arrow,
  &.tooltip-top-left .tooltip-arrow,
  &.tooltip-top-right .tooltip-arrow {
    border-top-color: #10b981;
  }
  &.tooltip-bottom .tooltip-arrow,
  &.tooltip-bottom-left .tooltip-arrow,
  &.tooltip-bottom-right .tooltip-arrow {
    border-bottom-color: #10b981;
  }
  &.tooltip-left .tooltip-arrow {
    border-left-color: #10b981;
  }
  &.tooltip-right .tooltip-arrow {
    border-right-color: #10b981;
  }
}

.tooltip.tooltip-error {
  background: #ef4444;
  
  &.tooltip-top .tooltip-arrow,
  &.tooltip-top-left .tooltip-arrow,
  &.tooltip-top-right .tooltip-arrow {
    border-top-color: #ef4444;
  }
  &.tooltip-bottom .tooltip-arrow,
  &.tooltip-bottom-left .tooltip-arrow,
  &.tooltip-bottom-right .tooltip-arrow {
    border-bottom-color: #ef4444;
  }
  &.tooltip-left .tooltip-arrow {
    border-left-color: #ef4444;
  }
  &.tooltip-right .tooltip-arrow {
    border-right-color: #ef4444;
  }
}

.tooltip.tooltip-warning {
  background: #f59e0b;
  
  &.tooltip-top .tooltip-arrow,
  &.tooltip-top-left .tooltip-arrow,
  &.tooltip-top-right .tooltip-arrow {
    border-top-color: #f59e0b;
  }
  &.tooltip-bottom .tooltip-arrow,
  &.tooltip-bottom-left .tooltip-arrow,
  &.tooltip-bottom-right .tooltip-arrow {
    border-bottom-color: #f59e0b;
  }
  &.tooltip-left .tooltip-arrow {
    border-left-color: #f59e0b;
  }
  &.tooltip-right .tooltip-arrow {
    border-right-color: #f59e0b;
  }
}
</style>