Skip to content

Pagination Component Code ​

Dependencies

This component requires:

  • Vue 3 with Composition API
  • SCSS support for styling
  • SVG support for navigation icons

Features

The pagination component includes:

  • First/Previous/Next/Last navigation buttons
  • Smart page number display with ellipsis
  • Multiple size variants (small, default, large)
  • Color themes (primary, secondary, success, error, warning, info)
  • Rounded button styling option
  • Full accessibility support with ARIA attributes
  • Customizable navigation controls via slots
  • Responsive design with smooth transitions

Full Component Code ​

vue
<script setup>
import { computed, defineEmits, defineProps, h } from 'vue';

const props = defineProps({
  modelValue: {
    type: Number,
    default: 1,
  },
  totalPages: {
    type: Number,
    required: true,
  },
  totalVisible: {
    type: Number,
    default: 7,
  },
  showFirstLast: {
    type: Boolean,
    default: true,
  },
  showPrevNext: {
    type: Boolean,
    default: true,
  },
  disabled: {
    type: Boolean,
    default: false,
  },
  size: {
    type: String,
    default: 'default',
    validator: (value) => ['small', 'default', 'large'].includes(value),
  },
  rounded: {
    type: [Boolean, String],
    default: false,
  },
  color: {
    type: String,
    default: 'primary',
  },
});

const emit = defineEmits(['update:modelValue', 'first', 'prev', 'next', 'last']);

// Default icon components
const defaultFirstIcon = () =>
  h(
    'svg',
    {
      viewBox: '0 0 24 24',
      width: 18,
      height: 18,
      fill: 'currentColor',
    },
    [h('path', { d: 'M18.41,16.59L13.82,12L18.41,7.41L17,6L11,12L17,18L18.41,16.59M6,6H8V18H6V6Z' })]
  );

const defaultPrevIcon = () =>
  h(
    'svg',
    {
      viewBox: '0 0 24 24',
      width: 18,
      height: 18,
      fill: 'currentColor',
    },
    [h('path', { d: 'M15.41,16.58L10.83,12L15.41,7.42L14,6L8,12L14,18L15.41,16.58Z' })]
  );

const defaultNextIcon = () =>
  h(
    'svg',
    {
      viewBox: '0 0 24 24',
      width: 18,
      height: 18,
      fill: 'currentColor',
    },
    [h('path', { d: 'M8.59,16.58L13.17,12L8.59,7.42L10,6L16,12L10,18L8.59,16.58Z' })]
  );

const defaultLastIcon = () =>
  h(
    'svg',
    {
      viewBox: '0 0 24 24',
      width: 18,
      height: 18,
      fill: 'currentColor',
    },
    [h('path', { d: 'M5.59,7.41L10.18,12L5.59,16.59L7,18L13,12L7,6L5.59,7.41M16,6H18V18H16V6Z' })]
  );

// Computed properties for button states
const isFirstDisabled = computed(() => props.disabled || props.modelValue <= 1);
const isPrevDisabled = computed(() => props.disabled || props.modelValue <= 1);
const isNextDisabled = computed(() => props.disabled || props.modelValue >= props.totalPages);
const isLastDisabled = computed(() => props.disabled || props.modelValue >= props.totalPages);

// Generate visible page numbers with ellipsis
const visiblePageNumbers = computed(() => {
  const { totalPages, totalVisible, modelValue } = props;

  if (totalPages <= totalVisible) {
    return Array.from({ length: totalPages }, (_, i) => i + 1);
  }

  const half = Math.floor(totalVisible / 2);
  let start = Math.max(1, modelValue - half);
  let end = Math.min(totalPages, start + totalVisible - 1);

  // Adjust if we're near the beginning
  if (end - start + 1 < totalVisible) {
    start = Math.max(1, end - totalVisible + 1);
  }

  const pages = [];

  // Add first page and ellipsis if needed
  if (start > 1) {
    pages.push(1);
    if (start > 2) {
      pages.push('...');
    }
  }

  // Add visible pages
  for (let i = start; i <= end; i++) {
    pages.push(i);
  }

  // Add ellipsis and last page if needed
  if (end < totalPages) {
    if (end < totalPages - 1) {
      pages.push('...');
    }
    pages.push(totalPages);
  }

  return pages;
});

// CSS classes
const paginationClasses = computed(() => ({
  [`o-pagination--${props.size}`]: props.size !== 'default',
  'o-pagination--disabled': props.disabled,
}));

const getButtonClasses = (active = false) => ({
  [`o-btn--${props.size}`]: props.size !== 'default',
  [`o-btn--${props.color}`]: active,
  'o-btn--active': active,
  'o-btn--disabled': props.disabled,
  'o-btn--rounded': props.rounded,
});

const firstButtonClasses = computed(() => getButtonClasses());
const prevButtonClasses = computed(() => getButtonClasses());
const nextButtonClasses = computed(() => getButtonClasses());
const lastButtonClasses = computed(() => getButtonClasses());

const getPageButtonClasses = (page) => getButtonClasses(page === props.modelValue);

// Get props for item slot
const getItemProps = (page) => ({
  class: getPageButtonClasses(page),
  'aria-label': `Go to page ${page}`,
  'aria-current': page === props.modelValue ? 'page' : undefined,
  onClick: () => handlePageClick(page),
  disabled: props.disabled,
});

// Event handlers
const handleFirstClick = (e) => {
  if (isFirstDisabled.value) return;
  emit('first', 1);
  emit('update:modelValue', 1);
};

const handlePrevClick = (e) => {
  if (isPrevDisabled.value) return;
  const prevPage = props.modelValue - 1;
  emit('prev', prevPage);
  emit('update:modelValue', prevPage);
};

const handleNextClick = (e) => {
  if (isNextDisabled.value) return;
  const nextPage = props.modelValue + 1;
  emit('next', nextPage);
  emit('update:modelValue', nextPage);
};

const handleLastClick = (e) => {
  if (isLastDisabled.value) return;
  emit('last', props.totalPages);
  emit('update:modelValue', props.totalPages);
};

const handlePageClick = (page) => {
  if (props.disabled || page < 1 || page > props.totalPages || page === props.modelValue) {
    return;
  }
  emit('update:modelValue', page);
};
</script>

<template>
  <nav class="o-pagination" :class="paginationClasses">
    <!-- First page button -->
    <template v-if="showFirstLast && totalPages > 0">
      <slot
        name="first"
        :icon="defaultFirstIcon"
        :onClick="handleFirstClick"
        :disabled="isFirstDisabled"
        :aria-label="'Go to first page'"
        :aria-disabled="isFirstDisabled"
      >
        <button
          class="o-btn o-btn--icon"
          :class="firstButtonClasses"
          :disabled="isFirstDisabled"
          :aria-label="'Go to first page'"
          :aria-disabled="isFirstDisabled"
          @click="handleFirstClick"
          type="button"
        >
          <component :is="defaultFirstIcon" />
        </button>
      </slot>
    </template>

    <!-- Previous page button -->
    <template v-if="showPrevNext">
      <slot
        name="prev"
        :icon="defaultPrevIcon"
        :onClick="handlePrevClick"
        :disabled="isPrevDisabled"
        :aria-label="'Go to previous page'"
        :aria-disabled="isPrevDisabled"
      >
        <button
          class="o-btn o-btn--icon"
          :class="prevButtonClasses"
          :disabled="isPrevDisabled"
          :aria-label="'Go to previous page'"
          :aria-disabled="isPrevDisabled"
          @click="handlePrevClick"
          type="button"
        >
          <component :is="defaultPrevIcon" />
        </button>
      </slot>
    </template>

    <!-- Page numbers -->
    <template v-for="(pageItem, pageIndex) in visiblePageNumbers" :key="`pagination-item-${pageIndex}`">
      <!-- Ellipsis -->
      <span v-if="pageItem === '...'" class="o-pagination__more">
        <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
          <path
            d="M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z"
          />
        </svg>
      </span>

      <!-- Page number button -->
      <template v-else>
        <slot name="item" :isActive="pageItem === modelValue" :page="pageItem" :props="getItemProps(pageItem)">
          <button
            class="o-btn o-btn--page"
            :class="getPageButtonClasses(pageItem)"
            :aria-label="`Go to page ${pageItem}`"
            :aria-current="pageItem === modelValue ? 'page' : undefined"
            @click="handlePageClick(pageItem)"
            type="button"
          >
            {{ pageItem }}
          </button>
        </slot>
      </template>
    </template>

    <!-- Next page button -->
    <template v-if="showPrevNext">
      <slot
        name="next"
        :icon="defaultNextIcon"
        :onClick="handleNextClick"
        :disabled="isNextDisabled"
        :aria-label="'Go to next page'"
        :aria-disabled="isNextDisabled"
      >
        <button
          class="o-btn o-btn--icon"
          :class="nextButtonClasses"
          :disabled="isNextDisabled"
          :aria-label="'Go to next page'"
          :aria-disabled="isNextDisabled"
          @click="handleNextClick"
          type="button"
        >
          <component :is="defaultNextIcon" />
        </button>
      </slot>
    </template>

    <!-- Last page button -->
    <template v-if="showFirstLast && totalPages > 0">
      <slot
        name="last"
        :icon="defaultLastIcon"
        :onClick="handleLastClick"
        :disabled="isLastDisabled"
        :aria-label="'Go to last page'"
        :aria-disabled="isLastDisabled"
      >
        <button
          class="o-btn o-btn--icon"
          :class="lastButtonClasses"
          :disabled="isLastDisabled"
          :aria-label="'Go to last page'"
          :aria-disabled="isLastDisabled"
          @click="handleLastClick"
          type="button"
        >
          <component :is="defaultLastIcon" />
        </button>
      </slot>
    </template>
  </nav>
</template>

<style lang="scss" scoped>
.o-pagination {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.5rem;
}

.o-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2.5rem;
  height: 2.5rem;
  padding: 0 0.5rem;
  border: none;
  border-radius: 0.5rem;
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1;
  cursor: pointer;
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  background: transparent;
  color: rgba(0, 0, 0, 0.87);
  position: relative;
  overflow: hidden;
}

.o-btn:hover:not(.o-btn--disabled) {
  background-color: rgba(0, 0, 0, 0.04);
}

.o-btn:focus {
  outline: none;
  box-shadow: 0 0 0 0.125rem rgba(25, 118, 210, 0.2);
}

.o-btn--disabled {
  cursor: default;
  color: rgba(0, 0, 0, 0.26) !important;
  pointer-events: none;
}

.o-btn--icon {
  min-width: 2.5rem;
  width: 2.5rem;
  border-radius: 0.5rem;
}

.o-btn--page {
  min-width: 2.5rem;
  border-radius: 0.5rem;
}
.o-btn--active {
  background-color: #1976d2 !important;
  color: white !important;
}

.o-btn--active:hover {
  background-color: #1565c0 !important;
}

.o-btn--rounded {
  border-radius: 50%;
}

/* Size variants */
.o-pagination--small .o-btn {
  min-width: 2rem;
  height: 2rem;
  font-size: 0.75rem;
}

.o-pagination--small .o-btn--icon {
  width: 2rem;
}

.o-pagination--large .o-btn {
  min-width: 3rem;
  height: 3rem;
  font-size: 1rem;
}

.o-pagination--large .o-btn--icon {
  width: 3rem;
}

.o-pagination__more {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2.5rem;
  height: 2.5rem;
  color: rgba(0, 0, 0, 0.6);
}

/* Ripple effect */
.o-btn::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: currentColor;
  opacity: 0;
  border-radius: inherit;
  transition: opacity 0.2s;
}

.o-btn:active::before {
  opacity: 0.12;
}

.o-btn--active::before {
  opacity: 0.12;
}

/* Color variants */
.o-btn--primary.o-btn--active {
  background-color: #1976d2 !important;
}

.o-btn--secondary.o-btn--active {
  background-color: #424242 !important;
}

.o-btn--success.o-btn--active {
  background-color: #388e3c !important;
}

.o-btn--error.o-btn--active {
  background-color: #d32f2f !important;
}

.o-btn--warning.o-btn--active {
  background-color: #f57c00 !important;
}

.o-btn--info.o-btn--active {
  background-color: #0288d1 !important;
}
</style>