Skip to content

Toast Component Source Code

The toast system consists of two main parts: the OToast.vue component and the useToast.js composable.

Dependencies

This implementation requires:

  • Vue 3 with Composition API
  • SCSS support for styling
  • uuid package for unique notification IDs

OToast.vue Component

vue
<script setup>
import { computed, inject, watch } from 'vue';
import { useToast } from '@/composables/useToastComposable';  

const convertRemToPixels = inject('convertRemToPixels', null);

const { notifications, removeNotification } = useToast();

const positions = new Set(['top-left', 'top', 'top-right', 'bottom-left', 'bottom', 'bottom-right']);

// Track which notifications are already scheduled for removal
const pendingRemovals = new Set();

// Watch for shouldRemove flag and remove notifications after animation
watch(
  () => notifications.value,
  (newNotifications) => {
    newNotifications.forEach((notification) => {
      if (notification.shouldRemove && !pendingRemovals.has(notification.id)) {
        pendingRemovals.add(notification.id);
        // Delay removal to allow exit animation
        setTimeout(() => {
          removeNotification(notification);
          pendingRemovals.delete(notification.id);
        }, 300);
      }
    });
  },
  { deep: true }
);

// Group notifications by position and sort by creation time
const notificationsByPosition = computed(() => {
  const grouped = {};
  notifications.value.forEach((notification) => {
    const position = notification.position || 'bottom-right';
    if (!grouped[position]) {
      grouped[position] = [];
    }
    grouped[position].push(notification);
  });

  // Sort notifications by creation time (newest first for proper stacking)
  Object.keys(grouped).forEach((position) => {
    grouped[position].sort((a, b) => b.createdAt - a.createdAt);
  });

  return grouped;
});

// Get container style for a specific position
const getContainerStyle = (position, offset = { x: 1, y: 1 }) => {
  let { x, y } = offset;

  // Convert rem to pixels
  x = convertRemToPixels ? convertRemToPixels(x) : x * parseInt(getComputedStyle(document.documentElement).fontSize);
  y = convertRemToPixels ? convertRemToPixels(y) : y * parseInt(getComputedStyle(document.documentElement).fontSize);

  const pos = positions.has(position) ? position : 'bottom-right';
  const styles = {};

  switch (pos) {
    case 'top-right':
      styles.top = `${y}px`;
      styles.right = `${x}px`;
      break;
    case 'top-left':
      styles.top = `${y}px`;
      styles.left = `${x}px`;
      break;
    case 'top':
      styles.top = `${y}px`;
      styles.left = '50%';
      styles.transform = 'translateX(-50%)';
      break;
    case 'bottom-left':
      styles.bottom = `${y}px`;
      styles.left = `${x}px`;
      break;
    case 'bottom':
      styles.bottom = `${y}px`;
      styles.left = '50%';
      styles.transform = 'translateX(-50%)';
      break;
    case 'bottom-right':
    default:
      styles.bottom = `${y}px`;
      styles.right = `${x}px`;
      break;
  }

  return styles;
};

// Check if position should have reverse order (for bottom positions)
const isReverseOrder = (position) => {
  return ['bottom-right', 'bottom-left', 'bottom'].includes(position);
};

// Handle close notification
const closeNotification = (notification) => {
  removeNotification(notification);
};
</script>

<template>
  <template v-for="(positionNotifications, position) in notificationsByPosition" :key="position">
    <div
      v-if="positionNotifications.length > 0"
      :class="['toast-container', `position-${position}`]"
      :style="getContainerStyle(position, positionNotifications[0]?.offset)"
    >
      <TransitionGroup
        name="toast"
        tag="div"
        class="toast-stack"
        :class="{ 'reverse-order': isReverseOrder(position) }"
      >
        <div
          v-for="notification in positionNotifications"
          :key="notification.id"
          :class="['toast-notification', `position-${position}`, ...notification.notificationClass]"
        >
          <div class="toast-content">
            <div v-if="notification.iconRef" class="toast-icon" v-html="notification.iconRef"></div>
            <div class="toast-messages">
              <div class="toast-primary-message">{{ notification.primaryMessage }}</div>
              <div v-if="notification.secondaryMessage" class="toast-secondary-message">
                {{ notification.secondaryMessage }}
              </div>
            </div>
            <button
              v-if="notification.showCloseButton"
              @click="closeNotification(notification)"
              class="toast-close-button"
              aria-label="Close notification"
            >
              <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
                <path
                  d="M10.5 3.5L3.5 10.5M3.5 3.5L10.5 10.5"
                  stroke="currentColor"
                  stroke-width="1.5"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                />
              </svg>
            </button>
          </div>
        </div>
      </TransitionGroup>
    </div>
  </template>
</template>

<style scoped lang="scss">
.toast-container {
  position: fixed;
  z-index: 10060;
  background: transparent;
  pointer-events: none; // Allow clicks to pass through container

  // Enable pointer events on toast notifications
  .toast-notification {
    pointer-events: auto;
  }
}

.toast-stack {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  max-height: 80vh; // Prevent toasts from going off-screen
  overflow: hidden; // Hide any overflow instead of scrolling

  // For bottom positions, reverse the order so latest appears at bottom
  .position-bottom-right &,
  .position-bottom-left &,
  .position-bottom & {
    flex-direction: column-reverse;
  }
}

.toast-notification {
  min-width: 18.75rem;
  max-width: 25rem;
  background: #ffffff;
  border-radius: 0.5rem;
  border: 0.0625rem solid #e5e7eb;
  overflow: hidden;
  box-shadow:
    0 0.25rem 0.75rem rgba(0, 0, 0, 0.1),
    0 0.125rem 0.25rem rgba(0, 0, 0, 0.05);
  transform: translateX(0);
  opacity: 1;
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  will-change: transform, opacity;
  .toast-content {
    display: flex;
    align-items: center;
    padding: 0.75rem 1rem;
    gap: 0.75rem;
  }

  .toast-icon {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    margin-top: 0.125rem;
  }

  .toast-messages {
    flex: 1;
    min-width: 0;
  }

  .toast-primary-message {
    font-size: 0.875rem;
    font-weight: 500;
    color: #111827;
    margin: 0;
    line-height: 1.4;
  }

  .toast-secondary-message {
    font-size: 0.8125rem;
    color: #6b7280;
    margin: 0.25rem 0 0 0;
    line-height: 1.4;
  }

  .toast-close-button {
    flex-shrink: 0;
    background: none;
    border: none;
    cursor: pointer;
    padding: 0.25rem;
    border-radius: 0.25rem;
    width: 1.5rem;
    height: 1.5rem;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.2s ease;

    svg {
      width: 0.875rem;
      height: 0.875rem;
    }

    &:hover {
      background-color: #f3f4f6;
    }
  }
  &.success {
    border-left: 4px solid #10b981;
    .toast-content {
      .toast-primary-message {
        color: #10b981;
      }
    }
  }

  &.error {
    border-left: 4px solid #ef4444;
    .toast-content {
      .toast-primary-message {
        color: #ef4444;
      }
    }
  }

  &.warning {
    border-left: 4px solid #f59e0b;
    .toast-content {
      .toast-primary-message {
        color: #f59e0b;
      }
    }
  }

  &.info {
    border-left: 4px solid #3b82f6;
    .toast-content {
      .toast-primary-message {
        color: #3b82f6;
      }
    }
  }
}

// =============================================================================
// ANIMATION STATES (using TransitionGroup)
// =============================================================================

// Enter animations
.toast-enter-active {
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.toast-leave-active {
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

// Base enter/leave states
.toast-enter-from {
  opacity: 0;
}

.toast-enter-to {
  opacity: 1;
  transform: translateY(0);
}

.toast-leave-from {
  opacity: 1;
  transform: translateY(0);
}

.toast-leave-to {
  opacity: 0;
}

// Position-specific animations for top positions (position class is on toast-notification)
.toast-notification.position-top-left.toast-enter-from,
.toast-notification.position-top.toast-enter-from,
.toast-notification.position-top-right.toast-enter-from {
  transform: translateY(-100%);
}

.toast-notification.position-top-left.toast-leave-to,
.toast-notification.position-top.toast-leave-to,
.toast-notification.position-top-right.toast-leave-to {
  transform: translateY(-100%);
}

// Position-specific animations for bottom positions
.toast-notification.position-bottom-left.toast-enter-from,
.toast-notification.position-bottom.toast-enter-from,
.toast-notification.position-bottom-right.toast-enter-from {
  transform: translateY(100%);
}

.toast-notification.position-bottom-left.toast-leave-to,
.toast-notification.position-bottom.toast-leave-to,
.toast-notification.position-bottom-right.toast-leave-to {
  transform: translateY(100%);
}

.toast-move {
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

// =============================================================================
// STACKING ANIMATIONS
// =============================================================================

// Stagger animation for multiple toasts appearing at once
.toast-stack {
  .toast-notification {
    &:nth-child(1) {
      transition-delay: 0ms;
    }
    &:nth-child(2) {
      transition-delay: 50ms;
    }
    &:nth-child(3) {
      transition-delay: 100ms;
    }
    &:nth-child(4) {
      transition-delay: 150ms;
    }
    &:nth-child(5) {
      transition-delay: 200ms;
    }
    &:nth-child(n + 6) {
      transition-delay: 250ms;
    }
  }
}
</style>

useToastComposable.js Composable

javascript
// composables/useToastComposable.js
import { ref, computed } from 'vue';
import { v4 as uuidv4 } from 'uuid';

// Global state for toast notifications
const notifications = ref([]);
const config = ref({
  position: 'bottom-right',
  offset: { x: 1, y: 1 }, // in rem
  defaultTimeout: 5000,
  maxNotifications: null,
});

let notificationId = 0;

export function useToast() {
  // Generate unique ID for each notification
  const generateId = () => uuidv4();

  // Default icons for different types
  const defaultIcons = {
    success: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
      <path d="M12.4735 4.80667C12.4115 4.74418 12.3378 4.69458 12.2565 4.66074C12.1753 4.62689 12.0881 4.60947 12.0001 4.60947C11.9121 4.60947 11.825 4.62689 11.7437 4.66074C11.6625 4.69458 11.5888 4.74418 11.5268 4.80667L6.56013 9.78L4.47346 7.68667C4.40911 7.62451 4.33315 7.57563 4.24992 7.54283C4.16668 7.51003 4.0778 7.49394 3.98834 7.49549C3.89889 7.49703 3.81062 7.51619 3.72857 7.55185C3.64651 7.58751 3.57229 7.63898 3.51013 7.70333C3.44797 7.76768 3.39909 7.84364 3.36629 7.92688C3.33349 8.01011 3.3174 8.099 3.31895 8.18845C3.3205 8.2779 3.33965 8.36618 3.37531 8.44823C3.41097 8.53028 3.46245 8.60451 3.5268 8.66667L6.0868 11.2267C6.14877 11.2892 6.22251 11.3387 6.30375 11.3726C6.38498 11.4064 6.47212 11.4239 6.56013 11.4239C6.64814 11.4239 6.73527 11.4064 6.81651 11.3726C6.89775 11.3387 6.97149 11.2892 7.03346 11.2267L12.4735 5.78667C12.5411 5.72424 12.5951 5.64847 12.6321 5.56414C12.669 5.4798 12.6881 5.38873 12.6881 5.29667C12.6881 5.2046 12.669 5.11353 12.6321 5.02919C12.5951 4.94486 12.5411 4.86909 12.4735 4.80667Z" fill="#10b981"/>
    </svg>`,
    error: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
      <path d="M8 1.33334C4.32 1.33334 1.33333 4.32001 1.33333 8.00001C1.33333 11.68 4.32 14.6667 8 14.6667C11.68 14.6667 14.6667 11.68 14.6667 8.00001C14.6667 4.32001 11.68 1.33334 8 1.33334ZM10.6667 9.78001L9.78 10.6667L8 8.88668L6.22 10.6667L5.33333 9.78001L7.11333 8.00001L5.33333 6.22001L6.22 5.33334L8 7.11334L9.78 5.33334L10.6667 6.22001L8.88667 8.00001L10.6667 9.78001Z" fill="#ef4444"/>
    </svg>`,
    warning: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
      <path d="M8.86602 2.5C8.62877 2.06698 8.33021 1.83333 8 1.83333C7.66979 1.83333 7.37123 2.06698 7.13398 2.5L1.20096 12.5C0.963708 12.933 0.963708 13.4003 1.20096 13.8333C1.43821 14.2664 1.73677 14.5 2.06699 14.5H13.933C14.2632 14.5 14.5618 14.2664 14.799 13.8333C15.0363 13.4003 15.0363 12.933 14.799 12.5L8.86602 2.5ZM8 6.16667C8.2301 6.16667 8.41667 6.35324 8.41667 6.58333V9.25C8.41667 9.4801 8.2301 9.66667 8 9.66667C7.7699 9.66667 7.58333 9.4801 7.58333 9.25V6.58333C7.58333 6.35324 7.7699 6.16667 8 6.16667ZM8 12.1667C7.5398 12.1667 7.16667 11.7936 7.16667 11.3333C7.16667 10.8731 7.5398 10.5 8 10.5C8.4602 10.5 8.83333 10.8731 8.83333 11.3333C8.83333 11.7936 8.4602 12.1667 8 12.1667Z" fill="#f59e0b"/>
    </svg>`,
    info: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
      <path d="M8 1.33334C4.32 1.33334 1.33333 4.32001 1.33333 8.00001C1.33333 11.68 4.32 14.6667 8 14.6667C11.68 14.6667 14.6667 11.68 14.6667 8.00001C14.6667 4.32001 11.68 1.33334 8 1.33334ZM8.66667 11.3333H7.33333V7.33334H8.66667V11.3333ZM8.66667 6.00001H7.33333V4.66668H8.66667V6.00001Z" fill="#3b82f6"/>
    </svg>`,
  };

  // Add a new toast notification
  const showToast = (options) => {
    const {
      type = 'info',
      primaryMessage,
      secondaryMessage = null,
      timeout = config.value.defaultTimeout,
      isPersistent = false,
      showCloseButton = true,
      icon = null,
      position = config.value.position, // Allow per-toast position
      offset = config.value.offset, // Allow per-toast offset
    } = options;

    // Validate required fields
    if (!primaryMessage) {
      console.warn('Toast notification requires a primaryMessage');
      return null;
    }

    // Create notification object
    const notification = {
      id: generateId(),
      type,
      primaryMessage,
      secondaryMessage,
      iconRef: icon || defaultIcons[type] || defaultIcons.info,
      showCloseButton,
      timeout,
      isPersistent,
      notificationClass: [type],
      position: position, // Store position per notification
      offset: { ...offset }, // Store offset per notification
      createdAt: Date.now(),
    };

    // Add to notifications array
    notifications.value.push(notification);

    // Apply max notifications limit per position (default to 5 if not set)
    const maxNotifications = config.value.maxNotifications || 5;
    const positionNotifications = notifications.value.filter((n) => n.position === position);

    if (positionNotifications.length > maxNotifications) {
      // Sort by creation time and remove oldest
      const sortedByTime = positionNotifications.sort((a, b) => a.createdAt - b.createdAt);
      const toRemove = sortedByTime.slice(0, positionNotifications.length - maxNotifications);

      notifications.value = notifications.value.filter((n) => !toRemove.some((remove) => remove.id === n.id));
    }

    // Auto-remove after timeout (unless persistent)
    if (!isPersistent && timeout > 0) {
      setTimeout(() => {
        // Mark notification for removal - the component will handle animation
        const index = notifications.value.findIndex((n) => n.id === notification.id);
        if (index > -1) {
          notifications.value[index] = { ...notifications.value[index], shouldRemove: true };
        }
      }, timeout);
    }

    return notification;
  };

  // Remove a specific notification
  const removeNotification = (notificationToRemove) => {
    const index = notifications.value.findIndex((n) => n.id === notificationToRemove.id);
    if (index > -1) {
      notifications.value.splice(index, 1);
    }
  };

  // Clear all notifications
  const clearAll = () => {
    notifications.value.length = 0;
  };

  // Convenience methods for different types
  const success = (primaryMessage, options = {}) => {
    return showToast({
      type: 'success',
      primaryMessage,
      ...options,
    });
  };

  const error = (primaryMessage, options = {}) => {
    return showToast({
      type: 'error',
      primaryMessage,
      isPersistent: true, // Errors should be persistent by default
      ...options,
    });
  };

  const warning = (primaryMessage, options = {}) => {
    return showToast({
      type: 'warning',
      primaryMessage,
      ...options,
    });
  };

  const info = (primaryMessage, options = {}) => {
    return showToast({
      type: 'info',
      primaryMessage,
      ...options,
    });
  };

  // Configuration methods
  const setPosition = (position) => {
    const validPositions = ['top-left', 'top', 'top-right', 'bottom-left', 'bottom', 'bottom-right'];

    if (validPositions.includes(position)) {
      config.value.position = position;
    } else {
      console.warn('Invalid toast position:', position);
    }
  };

  const setOffset = (x, y) => {
    config.value.offset = { x, y };
  };

  const setDefaultTimeout = (timeout) => {
    config.value.defaultTimeout = timeout;
  };

  const setMaxNotifications = (max) => {
    config.value.maxNotifications = max;
  };

  // Computed properties
  const hasNotifications = computed(() => notifications.value.length > 0);
  const notificationCount = computed(() => notifications.value.length);

  return {
    // State
    notifications: computed(() => notifications.value),
    config: computed(() => config.value),

    // Computed
    hasNotifications,
    notificationCount,

    // Methods
    showToast,
    removeNotification,
    clearAll,

    // Convenience methods
    success,
    error,
    warning,
    info,

    // Configuration
    setPosition,
    setOffset,
    setDefaultTimeout,
    setMaxNotifications,
  };
}

Installation

1. Install Dependencies

bash
npm install uuid
npm install --save-dev @types/uuid  # if using TypeScript

2. Add the Files

Create the files in your project:

  • src/components/OToast.vue
  • src/composables/useToast.js

3. Add to Your App

vue
<!-- App.vue or your root component -->
<template>
  <div id="app">
    <!-- Your app content -->
    
    <!-- Add toast component -->
    <OToast />
  </div>
</template>

<script setup>
import OToast from '@/components/OToast.vue'
</script>

4. Use Anywhere

vue
<script setup>
import { useToast } from '@/composables/useToast'

const { success, error } = useToast()

const handleClick = () => {
  success('Hello from toast!')
}
</script>

Features

  • Global State Management: Single source of truth for all notifications
  • Composable Architecture: Clean separation of concerns using Vue 3 Composition API
  • Auto-dismiss: Configurable timeouts with persistent option for critical messages
  • Type Safety: Built-in validation and error handling
  • Customizable: Full control over positioning, styling, and behavior
  • Accessible: Proper ARIA labels and keyboard interaction support
  • Performance: Efficient reactivity with minimal re-renders

Notes

  • The composable uses global singleton state - all instances share notifications
  • Error notifications are persistent by default for better UX
  • UUID ensures unique notification IDs even with rapid-fire notifications
  • The component handles responsive positioning and rem-to-pixel conversion
  • Stack direction reverses for bottom positions to show newest on top
  • Maximum notification limits help prevent UI overwhelming