Appearance
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>