Skip to content

Feedback Components

Qelos provides feedback components to inform users about system status, actions, and important events through visual and textual cues.

🚫 empty-state

Empty state illustration with call-to-action for when no data is available.

Basic Empty State

vue
<script setup>
import { ref } from 'vue'

const hasData = ref(false)
const data = ref([])
</script>

<template>
  <div class="empty-state-demo">
    <!-- Show empty state when no data -->
    <empty-state
      v-if="!hasData"
      image="/empty-illustration.svg"
      title="No data available"
      description="There's nothing to show here yet"
    >
      <el-button type="primary" @click="hasData = true">
        Load Sample Data
      </el-button>
    </empty-state>
    
    <!-- Show content when data exists -->
    <div v-else class="data-content">
      <data-card title="Data Loaded">
        <p>Your data has been loaded successfully!</p>
        <el-button @click="hasData = false">Show Empty State</el-button>
      </data-card>
    </div>
  </div>
</template>

Custom Empty State

vue
<script setup>
import { ref } from 'vue'

const searchQuery = ref('')
const searchResults = ref([])

const handleSearch = async () => {
  // Simulate search
  await new Promise(resolve => setTimeout(resolve, 500))
  searchResults.value = [] // No results
}

const clearSearch = () => {
  searchQuery.value = ''
  searchResults.value = []
}
</script>

<template>
  <div class="search-container">
    <div class="search-bar">
      <el-input
        v-model="searchQuery"
        placeholder="Search for products..."
        @keyup.enter="handleSearch"
      >
        <template #append>
          <el-button @click="handleSearch">Search</el-button>
        </template>
      </el-input>
    </div>
    
    <!-- Empty state for no search results -->
    <empty-state
      v-if="searchQuery && searchResults.length === 0"
      image="/no-results.svg"
      title="No results found"
      :description="`No products match "${searchQuery}"`"
    >
      <template #actions>
        <el-button @click="clearSearch">Clear Search</el-button>
        <el-button type="primary">Browse All Products</el-button>
      </template>
    </empty-state>
    
    <!-- Results list -->
    <div v-else-if="searchResults.length > 0" class="results-list">
      <!-- Display results -->
    </div>
  </div>
</template>

<style scoped>
.search-container {
  max-width: 600px;
  margin: 0 auto;
}

.search-bar {
  margin-bottom: 30px;
}
</style>

Error Empty State

vue
<script setup>
import { ref } from 'vue'

const errorState = ref({
  hasError: false,
  message: 'Failed to load data',
  code: 'NETWORK_ERROR'
})

const retry = async () => {
  errorState.value.hasError = false
  try {
    // Retry loading data
    await loadData()
  } catch (error) {
    errorState.value.hasError = true
    errorState.value.message = error.message
  }
}
</script>

<template>
  <empty-state
    v-if="errorState.hasError"
    image="/error-illustration.svg"
    title="Oops! Something went wrong"
    :description="errorState.message"
  >
    <template #extra>
      <el-tag type="danger" size="small">
        Error code: {{ errorState.code }}
      </el-tag>
    </template>
    
    <template #actions>
      <el-button @click="retry" :loading="loading">
        Try Again
      </el-button>
      <el-button type="primary" @click="contactSupport">
        Contact Support
      </el-button>
    </template>
  </empty-state>
</template>

Props

PropTypeDefaultDescription
imagestring-Illustration image URL
titlestring'No Data'Empty state title
descriptionstring-Descriptive text
iconstring-Icon name (alternative to image)
sizestring'medium'Size: small, medium, large

Slots

SlotDescription
defaultCustom content area
actionsAction buttons
extraExtra information

📊 loading-bar

Progress bar for async operations with customizable styles and animations.

Basic Loading Bar

vue
<script setup>
import { ref } from 'vue'

const isLoading = ref(false)
const progress = ref(0)

const startLoading = () => {
  isLoading.value = true
  progress.value = 0
  
  const interval = setInterval(() => {
    progress.value += Math.random() * 30
    if (progress.value >= 100) {
      progress.value = 100
      clearInterval(interval)
      setTimeout(() => {
        isLoading.value = false
      }, 500)
    }
  }, 200)
}
</script>

<template>
  <div class="loading-demo">
    <loading-bar
      :loading="isLoading"
      :progress="progress"
      height="3px"
    />
    
    <el-button @click="startLoading" :disabled="isLoading">
      Start Loading
    </el-button>
  </div>
</template>

Multiple Loading Bars

vue
<script setup>
import { ref } from 'vue'

const tasks = ref([
  { id: 1, name: 'Uploading files', loading: false, progress: 0 },
  { id: 2, name: 'Processing data', loading: false, progress: 0 },
  { id: 3, name: 'Generating report', loading: false, progress: 0 }
])

const runTask = async (task) => {
  task.loading = true
  task.progress = 0
  
  const interval = setInterval(() => {
    task.progress += Math.random() * 40
    if (task.progress >= 100) {
      task.progress = 100
      clearInterval(interval)
      setTimeout(() => {
        task.loading = false
      }, 500)
    }
  }, 300)
}

const runAllTasks = async () => {
  for (const task of tasks.value) {
    runTask(task)
    await new Promise(resolve => setTimeout(resolve, 500))
  }
}
</script>

<template>
  <div class="multi-loading">
    <div class="loading-header">
      <h3>Task Progress</h3>
      <el-button @click="runAllTasks" :disabled="tasks.some(t => t.loading)">
        Run All Tasks
      </el-button>
    </div>
    
    <div class="task-list">
      <div
        v-for="task in tasks"
        :key="task.id"
        class="task-item"
      >
        <div class="task-info">
          <span>{{ task.name }}</span>
          <span v-if="task.loading">{{ Math.round(task.progress) }}%</span>
        </div>
        
        <loading-bar
          :loading="task.loading"
          :progress="task.progress"
          :color="task.progress === 100 ? '#67c23a' : '#409eff'"
          height="6px"
        />
      </div>
    </div>
  </div>
</template>

<style scoped>
.multi-loading {
  max-width: 500px;
  margin: 0 auto;
}

.loading-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.task-list {
  display: flex;
  flex-direction: column;
  gap: 15px;
}

.task-item {
  padding: 15px;
  background: var(--qelos-surface);
  border-radius: 4px;
}

.task-info {
  display: flex;
  justify-content: space-between;
  margin-bottom: 8px;
  font-size: 14px;
}
</style>

Page Loading Bar

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

const pageLoading = ref(false)
const pageProgress = ref(0)

// Simulate page loading
onMounted(() => {
  pageLoading.value = true
  
  const interval = setInterval(() => {
    pageProgress.value += 10
    if (pageProgress.value >= 100) {
      clearInterval(interval)
      setTimeout(() => {
        pageLoading.value = false
      }, 300)
    }
  }, 100)
})
</script>

<template>
  <div class="page-container">
    <!-- Fixed top loading bar -->
    <loading-bar
      :loading="pageLoading"
      :progress="pageProgress"
      :fixed="true"
      :top="0"
      color="linear-gradient(90deg, #409eff, #67c23a)"
      height="3px"
    />
    
    <!-- Page content -->
    <div class="page-content">
      <h1>Page Title</h1>
      <p>Content loads while progress bar shows at top</p>
    </div>
  </div>
</template>

<style scoped>
.page-container {
  position: relative;
  min-height: 100vh;
}

.page-content {
  padding: 20px;
  transition: opacity 0.3s;
}

.page-content:has(+ .loading-bar[loading="true"]) {
  opacity: 0.7;
}
</style>

Props

PropTypeDefaultDescription
loadingbooleanfalseLoading state
progressnumber0Progress percentage (0-100)
heightstring/number'2px'Bar height
colorstring'#409eff'Bar color
fixedbooleanfalseFixed position
topstring/number0Top position when fixed
animatedbooleantrueEnable animations

🔔 toast-notification

Toast notifications for displaying brief, non-intrusive messages to users.

Basic Toast

vue
<script setup>
import { ref } from 'vue'

const showSuccess = () => {
  Toast.success('Operation completed successfully!')
}

const showError = () => {
  Toast.error('An error occurred. Please try again.')
}

const showWarning = () => {
  Toast.warning('Please review your input before submitting.')
}

const showInfo = () => {
  Toast.info('New updates are available.')
}
</script>

<template>
  <div class="toast-demo">
    <el-button @click="showSuccess">Success</el-button>
    <el-button @click="showError">Error</el-button>
    <el-button @click="showWarning">Warning</el-button>
    <el-button @click="showInfo">Info</el-button>
  </div>
</template>

Custom Toast

vue
<script setup>
import { ref } from 'vue'

const showCustomToast = () => {
  Toast({
    message: 'File uploaded successfully',
    type: 'success',
    duration: 5000,
    showClose: true,
    onClick: () => {
      console.log('Toast clicked')
    },
    action: {
      text: 'View',
      onClick: () => {
        console.log('View action clicked')
      }
    }
  })
}

const showProgressToast = () => {
  const toast = Toast({
    message: 'Uploading file...',
    duration: 0, // No auto close
    showProgress: true,
    progress: 0
  })
  
  // Simulate progress
  let progress = 0
  const interval = setInterval(() => {
    progress += 10
    toast.updateProgress(progress)
    
    if (progress >= 100) {
      clearInterval(interval)
      toast.close()
      Toast.success('Upload complete!')
    }
  }, 200)
}
</script>

<template>
  <div class="custom-toast-demo">
    <el-button @click="showCustomToast">Custom Toast</el-button>
    <el-button @click="showProgressToast">Progress Toast</el-button>
  </div>
</template>

Toast Container

vue
<script setup>
import { ref } from 'vue'

const notifications = ref([])

const addNotification = (type, message) => {
  const id = Date.now()
  notifications.value.push({
    id,
    type,
    message,
    timestamp: new Date()
  })
  
  // Auto remove after 5 seconds
  setTimeout(() => {
    removeNotification(id)
  }, 5000)
}

const removeNotification = (id) => {
  const index = notifications.value.findIndex(n => n.id === id)
  if (index > -1) {
    notifications.value.splice(index, 1)
  }
}

const clearAll = () => {
  notifications.value = []
}
</script>

<template>
  <div class="notification-center">
    <div class="notification-controls">
      <el-button @click="addNotification('success', 'Task completed')">
        Add Success
      </el-button>
      <el-button @click="addNotification('error', 'Failed to save')">
        Add Error
      </el-button>
      <el-button @click="addNotification('info', 'System update available')">
        Add Info
      </el-button>
      <el-button @click="clearAll" :disabled="!notifications.length">
        Clear All
      </el-button>
    </div>
    
    <div class="notification-list">
      <transition-group name="notification">
        <div
          v-for="notification in notifications"
          :key="notification.id"
          class="notification-item"
          :class="`notification--${notification.type}`"
        >
          <div class="notification-content">
            <el-icon>
              <component :is="getIcon(notification.type)" />
            </el-icon>
            <span>{{ notification.message }}</span>
          </div>
          
          <div class="notification-meta">
            <span class="time">
              {{ formatTime(notification.timestamp) }}
            </span>
            <el-button
              text
              size="small"
              @click="removeNotification(notification.id)"
            >
              <el-icon><close /></el-icon>
            </el-button>
          </div>
        </div>
      </transition-group>
    </div>
  </div>
</template>

<style scoped>
.notification-center {
  max-width: 400px;
  margin: 0 auto;
}

.notification-controls {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
  flex-wrap: wrap;
}

.notification-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.notification-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  background: white;
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  border-left: 4px solid;
}

.notification--success {
  border-left-color: var(--qelos-success);
}

.notification--error {
  border-left-color: var(--qelos-danger);
}

.notification--info {
  border-left-color: var(--qelos-info);
}

.notification-content {
  display: flex;
  align-items: center;
  gap: 8px;
}

.notification-meta {
  display: flex;
  align-items: center;
  gap: 8px;
}

.time {
  font-size: 12px;
  color: var(--qelos-text-secondary);
}

/* Transitions */
.notification-enter-active,
.notification-leave-active {
  transition: all 0.3s ease;
}

.notification-enter-from {
  opacity: 0;
  transform: translateX(100%);
}

.notification-leave-to {
  opacity: 0;
  transform: translateX(100%);
}

.notification-move {
  transition: transform 0.3s ease;
}
</style>

📱 skeleton-loader

Skeleton loading placeholder for various content types while data is loading.

Text Skeletons

vue
<script setup>
import { ref } from 'vue'

const loading = ref(true)
const content = ref('')

const loadContent = async () => {
  loading.value = true
  await new Promise(resolve => setTimeout(resolve, 2000))
  content.value = 'This is the loaded content. It appears after the skeleton loader.'
  loading.value = false
}
</script>

<template>
  <div class="skeleton-demo">
    <el-button @click="loadContent" :disabled="loading">
      Load Content
    </el-button>
    
    <div class="content-container">
      <!-- Show skeleton while loading -->
      <skeleton-loader
        v-if="loading"
        type="text"
        :lines="3"
        animated
      />
      
      <!-- Show actual content when loaded -->
      <div v-else class="loaded-content">
        <p>{{ content }}</p>
      </div>
    </div>
  </div>
</template>

<style scoped>
.content-container {
  margin-top: 20px;
  padding: 20px;
  background: var(--qelos-surface);
  border-radius: 4px;
}
</style>

Card Skeleton

vue
<script setup>
import { ref } from 'vue'

const loadingCards = ref(true)
const cards = ref([])

const loadCards = async () => {
  loadingCards.value = true
  await new Promise(resolve => setTimeout(resolve, 2000))
  cards.value = Array(3).fill(0).map((_, i) => ({
    id: i + 1,
    title: `Card ${i + 1}`,
    description: `Description for card ${i + 1}`
  }))
  loadingCards.value = false
}
</script>

<template>
  <div class="card-skeletons">
    <el-button @click="loadCards" :disabled="loadingCards">
      Load Cards
    </el-button>
    
    <grid-layout :cols="{ xs: 1, md: 3 }" gap="20" style="margin-top: 20px">
      <!-- Show skeleton cards while loading -->
      <template v-if="loadingCards">
        <skeleton-loader
          v-for="i in 3"
          :key="i"
          type="card"
          :height="200"
          animated
        />
      </template>
      
      <!-- Show actual cards when loaded -->
      <data-card
        v-else
        v-for="card in cards"
        :key="card.id"
        :title="card.title"
      >
        {{ card.description }}
      </data-card>
    </grid-layout>
  </div>
</template>

Table Skeleton

vue
<script setup>
import { ref } from 'vue'

const loadingTable = ref(true)
const tableData = ref([])

const columns = [
  { key: 'name', label: 'Name' },
  { key: 'email', label: 'Email' },
  { key: 'role', label: 'Role' },
  { key: 'status', label: 'Status' }
]

const loadTableData = async () => {
  loadingTable.value = true
  await new Promise(resolve => setTimeout(resolve, 1500))
  tableData.value = Array(5).fill(0).map((_, i) => ({
    id: i + 1,
    name: `User ${i + 1}`,
    email: `user${i + 1}@example.com`,
    role: ['Admin', 'User', 'Editor'][i % 3],
    status: ['Active', 'Inactive'][i % 2]
  }))
  loadingTable.value = false
}
</script>

<template>
  <div class="table-skeleton">
    <el-button @click="loadTableData" :disabled="loadingTable">
      Load Table Data
    </el-button>
    
    <div style="margin-top: 20px">
      <!-- Show table skeleton while loading -->
      <skeleton-loader
        v-if="loadingTable"
        type="table"
        :rows="5"
        :columns="4"
        animated
      />
      
      <!-- Show actual table when loaded -->
      <quick-table
        v-else
        :data="tableData"
        :columns="columns"
      />
    </div>
  </div>
</template>

Custom Skeleton

vue
<script setup>
import { ref } from 'vue'

const loading = ref(true)

const customSkeleton = (
  <div class="custom-skeleton">
    <div class="skeleton-avatar"></div>
    <div class="skeleton-content">
      <div class="skeleton-line" style="width: 60%"></div>
      <div class="skeleton-line" style="width: 40%"></div>
      <div class="skeleton-line" style="width: 80%"></div>
    </div>
  </div>
)
</script>

<template>
  <div class="custom-skeleton-demo">
    <h3>User Profile</h3>
    
    <!-- Show custom skeleton while loading -->
    <div v-if="loading" class="skeleton-wrapper">
      <skeleton-loader
        type="custom"
        :template="customSkeleton"
        animated
      />
    </div>
    
    <!-- Show actual content when loaded -->
    <div v-else class="user-profile">
      <avatar src="/user-avatar.jpg" :size="60" />
      <div class="user-info">
        <h4>John Doe</h4>
        <p>john.doe@example.com</p>
        <p>Software Engineer</p>
      </div>
    </div>
  </div>
</template>

<style scoped>
.custom-skeleton-demo {
  max-width: 400px;
  padding: 20px;
  background: var(--qelos-surface);
  border-radius: 8px;
}

.skeleton-wrapper {
  padding: 20px 0;
}

.user-profile {
  display: flex;
  gap: 15px;
  align-items: center;
}

.user-info h4 {
  margin: 0 0 5px 0;
}

.user-info p {
  margin: 0;
  color: var(--qelos-text-secondary);
  font-size: 14px;
}
</style>

Props

PropTypeDefaultDescription
typestring'text'Skeleton type: text, card, table, custom
linesnumber3Number of lines (for text type)
rowsnumber5Number of rows (for table type)
columnsnumber4Number of columns (for table type)
heightstring/number-Skeleton height
animatedbooleantrueEnable shimmer animation
templateVNode-Custom template (for custom type)

📚 Next Steps

Build SaaS Products Without Limits.