Pagination
Page navigation controls.
Installation
$
npx @309-thingspire/ui@latest add paginationUsage
import { Pagination } from "@/components/pagination/pagination"<Pagination />Examples
Live preview rendered from Pagination.preview.tsx. Switch to the Code tab to view the underlying component source.
Loading preview…
import React from 'react';
import { border, colors, radius, shadows, spacing, typography } from '../../style-tokens';
import { IconArrowLeftSLine, IconArrowRightSLine } from '../icons';
import type { PaginationInteractionState, PaginationNumberItem, PaginationProps, PaginationSize, PaginationType } from './Pagination.types';
type TypographyToken = {
fontFamily: string;
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing: number;
};
type SizeConfig = {
iconSize: number;
iconPadding: number;
iconGap: number;
numberItemSize: number;
numberRadius: number;
numberTypography: TypographyToken;
buttonPaddingX: number;
buttonPaddingY: number;
buttonGap: number;
buttonRadius: number;
buttonTypography: TypographyToken;
numbersContainerWidth: number;
};
const palette = colors.primitive.palette;
const textBase = colors.semantic.theme.text.base;
const SIZE_CONFIG: Record<PaginationSize, SizeConfig> = {
md: {
iconSize: spacing.scale['20'],
iconPadding: spacing.scale['10'],
iconGap: spacing.scale['4'],
numberItemSize: spacing.scale['40'],
numberRadius: radius.scale.xl,
numberTypography: typography.scale.bodyS.medium,
buttonPaddingX: spacing.scale['12'],
buttonPaddingY: spacing.scale['10'],
buttonGap: spacing.scale['4'],
buttonRadius: radius.scale.xl,
buttonTypography: typography.scale.captionL.medium,
numbersContainerWidth: spacing.scale['400'] + spacing.scale['24'],
},
sm: {
iconSize: spacing.scale['16'],
iconPadding: spacing.scale['8'],
iconGap: spacing.scale['2'],
numberItemSize: spacing.scale['32'],
numberRadius: radius.scale.lg,
numberTypography: typography.scale.captionL.medium,
buttonPaddingX: spacing.scale['10'],
buttonPaddingY: spacing.scale['6'],
buttonGap: spacing.scale['2'],
buttonRadius: radius.scale.lg,
buttonTypography: typography.scale.captionL.medium,
numbersContainerWidth: spacing.scale['320'] + spacing.scale['32'],
},
};
const TYPE_CONTAINER_WIDTH: Record<PaginationType, Record<PaginationSize, number>> = {
arrows: {
md: spacing.scale['400'],
sm: spacing.scale['400'],
},
numbers: {
md: spacing.scale['400'] + spacing.scale['24'],
sm: spacing.scale['320'] + spacing.scale['32'],
},
buttons: {
md: spacing.scale['400'],
sm: spacing.scale['400'],
},
};
const DEFAULT_NUMBER_ITEMS: PaginationNumberItem[] = [
{ id: 'page-1', label: '1' },
{ id: 'more-left', label: '...', kind: 'more', disabled: true },
{ id: 'page-56', label: '56' },
{ id: 'page-57', label: '57', active: true },
{ id: 'page-58', label: '58' },
{ id: 'more-right', label: '...', kind: 'more', disabled: true },
{ id: 'page-100', label: '100' },
];
function toTypographyStyle(token: TypographyToken) {
return {
fontFamily: token.fontFamily,
fontSize: token.fontSize,
fontWeight: token.fontWeight,
lineHeight: `${token.lineHeight}px`,
letterSpacing: `${token.letterSpacing}px`,
};
}
function withFocusRing(baseShadow: string, interactionState: PaginationInteractionState, disabled: boolean): string {
if (interactionState !== 'focus' || disabled) {
return baseShadow;
}
if (baseShadow === 'none') {
return shadows.focusRing.light.css;
}
return `${baseShadow}, ${shadows.focusRing.light.css}`;
}
function ArrowIcon({ direction, size }: { direction: 'left' | 'right'; size: PaginationSize }) {
const config = SIZE_CONFIG[size];
const Icon = direction === 'left' ? IconArrowLeftSLine : IconArrowRightSLine;
return (
<Icon
aria-hidden="true"
style={{
width: config.iconSize,
height: config.iconSize,
display: 'block',
}}
/>
);
}
function ArrowControlButton({
size,
direction,
interactionState,
disabled,
onClick,
ghost,
}: {
size: PaginationSize;
direction: 'left' | 'right';
interactionState: PaginationInteractionState;
disabled: boolean;
onClick?: () => void;
ghost: boolean;
}) {
const config = SIZE_CONFIG[size];
return (
<button
type="button"
disabled={disabled}
onClick={!disabled ? onClick : undefined}
aria-disabled={disabled || undefined}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: config.iconGap,
borderStyle: 'solid',
borderWidth: border.width['0'],
borderRadius: ghost ? config.numberRadius : radius.scale.full,
backgroundColor: ghost ? palette.base.transparent : palette.gray['1a'],
padding: config.iconPadding,
cursor: disabled ? 'default' : 'pointer',
boxShadow: withFocusRing('none', interactionState, disabled),
}}
>
<ArrowIcon direction={direction} size={size} />
</button>
);
}
function Dots({
count,
activeIndex,
}: {
count: number;
activeIndex: number;
}) {
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: spacing.scale['4'],
}}
>
{Array.from({ length: count }, (_, index) => (
<span
key={`dot-${index}`}
aria-hidden="true"
style={{
width: spacing.scale['6'],
height: spacing.scale['6'],
borderRadius: radius.scale.full,
backgroundColor: index === activeIndex ? palette.base.dark1 : palette.gray['2'],
display: 'inline-flex',
flexShrink: 0,
}}
/>
))}
</div>
);
}
function NumberItemButton({
item,
size,
interactionState,
disabled,
onNumberClick,
}: {
item: PaginationNumberItem;
size: PaginationSize;
interactionState: PaginationInteractionState;
disabled: boolean;
onNumberClick?: (id: string) => void;
}) {
const config = SIZE_CONFIG[size];
const itemDisabled = Boolean(disabled || item.disabled);
const isActive = Boolean(item.active) && !itemDisabled;
return (
<button
type="button"
disabled={itemDisabled}
aria-current={isActive ? 'page' : undefined}
aria-disabled={itemDisabled || undefined}
onClick={!itemDisabled && item.kind !== 'more' ? () => onNumberClick?.(item.id) : undefined}
style={{
width: config.numberItemSize,
height: config.numberItemSize,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderStyle: 'solid',
borderWidth: border.width['0'],
borderRadius: config.numberRadius,
backgroundColor: isActive ? palette.gray['1a'] : palette.base.transparent,
padding: spacing.scale['0'],
cursor: itemDisabled || item.kind === 'more' ? 'default' : 'pointer',
boxShadow: withFocusRing('none', interactionState, itemDisabled),
}}
>
<span
style={{
color: itemDisabled
? textBase.staticDarkQuaternary
: isActive
? textBase.staticDark
: textBase.staticDarkSecondary,
textAlign: 'center',
whiteSpace: 'nowrap',
...toTypographyStyle(config.numberTypography),
}}
>
{item.label}
</span>
</button>
);
}
function ActionButton({
size,
interactionState,
disabled,
label,
primary,
onClick,
}: {
size: PaginationSize;
interactionState: PaginationInteractionState;
disabled: boolean;
label: string;
primary: boolean;
onClick?: () => void;
}) {
const config = SIZE_CONFIG[size];
return (
<button
type="button"
disabled={disabled}
aria-disabled={disabled || undefined}
onClick={!disabled ? onClick : undefined}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: config.buttonGap,
borderStyle: 'solid',
borderWidth: primary ? border.width['0'] : border.width['1'],
borderColor: primary ? palette.base.transparent : palette.gray['3'],
borderRadius: config.buttonRadius,
backgroundColor: primary ? palette.gray['13'] : palette.base.white,
paddingInline: config.buttonPaddingX,
paddingBlock: config.buttonPaddingY,
cursor: disabled ? 'default' : 'pointer',
boxShadow: withFocusRing(shadows.elevation.xs.css, interactionState, disabled),
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
paddingInline: spacing.scale['4'],
paddingBlock: spacing.scale['0'],
color: disabled
? textBase.staticDarkQuaternary
: primary
? textBase.staticWhite
: textBase.staticDark,
textAlign: 'center',
whiteSpace: 'nowrap',
...toTypographyStyle(config.buttonTypography),
}}
>
{label}
</span>
</button>
);
}
export function Pagination({
type = 'numbers',
size = 'md',
interactionState = 'default',
showDots = true,
dotCount = spacing.scale['6'],
activeDotIndex = spacing.scale['0'],
leftButtonLabel = 'Button',
rightButtonLabel = 'Button',
numberItems = DEFAULT_NUMBER_ITEMS,
onPrevClick,
onNextClick,
onNumberClick,
onLeftButtonClick,
onRightButtonClick,
className,
style,
...props
}: PaginationProps) {
const config = SIZE_CONFIG[size];
const disabled = interactionState === 'disabled';
const width = TYPE_CONTAINER_WIDTH[type][size];
if (type === 'arrows') {
return (
<div
role="navigation"
aria-label="Pagination"
className={className}
style={{
width,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: spacing.scale['0'],
boxSizing: 'border-box',
...style,
}}
{...props}
>
<ArrowControlButton
size={size}
direction="left"
interactionState={interactionState}
disabled={disabled}
onClick={onPrevClick}
ghost={false}
/>
{showDots ? <Dots count={dotCount} activeIndex={activeDotIndex} /> : null}
<ArrowControlButton
size={size}
direction="right"
interactionState={interactionState}
disabled={disabled}
onClick={onNextClick}
ghost={false}
/>
</div>
);
}
if (type === 'buttons') {
return (
<div
role="navigation"
aria-label="Pagination"
className={className}
style={{
width,
display: 'inline-flex',
alignItems: 'center',
gap: spacing.scale['16'],
padding: spacing.scale['0'],
boxSizing: 'border-box',
...style,
}}
{...props}
>
<div
style={{
flex: '1 0 0',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
minWidth: spacing.scale['0'],
}}
>
<ActionButton
size={size}
interactionState={interactionState}
disabled={disabled}
label={leftButtonLabel}
primary={false}
onClick={onLeftButtonClick}
/>
</div>
{showDots ? <Dots count={dotCount} activeIndex={activeDotIndex} /> : null}
<div
style={{
flex: '1 0 0',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
minWidth: spacing.scale['0'],
}}
>
<ActionButton
size={size}
interactionState={interactionState}
disabled={disabled}
label={rightButtonLabel}
primary
onClick={onRightButtonClick}
/>
</div>
</div>
);
}
return (
<div
role="navigation"
aria-label="Pagination"
className={className}
style={{
width: config.numbersContainerWidth,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.scale['8'],
padding: spacing.scale['0'],
boxSizing: 'border-box',
...style,
}}
{...props}
>
<ArrowControlButton
size={size}
direction="left"
interactionState={interactionState}
disabled={disabled}
onClick={onPrevClick}
ghost
/>
{numberItems.map((item) => (
<NumberItemButton
key={item.id}
item={item}
size={size}
interactionState={interactionState}
disabled={disabled}
onNumberClick={onNumberClick}
/>
))}
<ArrowControlButton
size={size}
direction="right"
interactionState={interactionState}
disabled={disabled}
onClick={onNextClick}
ghost
/>
</div>
);
}
API Reference
Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)