Glass paginations
Five frosted pagination shells that vary only the glass surface: a soft frost, a primary tint, a smoky scrim, a clear crystal and a layered depth. Each carries its own page state, is sized, token-driven and marks the active page with aria-current.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/pagination-glassDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/pagination-glass.tsx"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
import { glassDepth } from "@/components/ui/glass"
/* Glass pagination family: 5 frosted paginators. They all share the same skeleton;
the difference is ONLY on the surface of the container (tone, edge, depth). The
glass is NO LONGER hand-written: every variant derives from the glassDepth scale
inside @ai2/glass, so the ai2 signature (top inset highlight + ambient shadow) is
the same language across all variants.
Choosing the depth (a bar-shaped control): a paginator is wide but SHORT and sits
in the page flow; behind a short bar a heavy blur brings no gain, so the scale
starts low and only the genuinely floating variant reaches the top.
NO GLASS ON GLASS: the glass surface is the BAR itself; the active page is a
separate highlight on top of it (bg-primary, opaque) - a second glass layer would
have turned to mud. primary here is not a decorative tint but the semantic role
of the active/selected state.
Color comes only from semantic tokens; alpha via color-mix. The active page
slides with a framer layoutId, and the layoutId derives from React.useId() (there
are many instances on the same page). */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const iconSize: Record<StyledSize, string> = {
sm: "size-8",
md: "size-9",
lg: "size-10",
xl: "size-12",
}
const padSize: Record<StyledSize, string> = {
sm: "p-1",
md: "p-1",
lg: "p-1.5",
xl: "p-1.5",
}
const focusRing =
"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
const btnBase =
"relative inline-flex shrink-0 select-none items-center justify-center font-medium whitespace-nowrap transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
interface PaginationProps {
className?: string
size?: StyledSize
total?: number
defaultPage?: number
}
/* Dahili sayfa durumu: tiklama gercekten sayfayi degistirir. */
function usePageState(total: number, defaultPage: number) {
const n = Math.max(total, 1)
const clamp = React.useCallback((p: number) => Math.min(Math.max(p, 1), n), [n])
const [current, setCurrent] = React.useState(() => clamp(defaultPage))
const goto = (p: number) => setCurrent(clamp(p))
return { current: clamp(current), total: n, goto }
}
/* 1 ... k-1 k k+1 ... N sekilli ellipsis'li dizi. */
function pageRange(current: number, total: number): (number | "ellipsis")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
const out: (number | "ellipsis")[] = [1]
const left = Math.max(2, current - 1)
const right = Math.min(total - 1, current + 1)
if (left > 2) out.push("ellipsis")
for (let p = left; p <= right; p++) out.push(p)
if (right < total - 1) out.push("ellipsis")
out.push(total)
return out
}
/* Shared frosted shell: shellClassName carries each variant's glass technique, activeClassName the sliding highlight of the active page. */
function GlassShell({
props,
shellClassName,
activeClassName,
idleClassName,
}: {
props: PaginationProps
shellClassName: string
activeClassName: string
idleClassName: string
}) {
const { className, size = "md", total = 8, defaultPage = 1 } = props
const reduce = useReducedMotion()
const { current, total: n, goto } = usePageState(total, defaultPage)
const items = pageRange(current, n)
const uid = React.useId()
return (
<nav
data-slot="styled-pagination"
aria-label="Pagination"
className={cn("inline-flex items-center gap-1 rounded-full", padSize[size], shellClassName, className)}
>
<button
type="button"
aria-label="Go to previous page"
disabled={current <= 1}
onClick={() => goto(current - 1)}
className={cn(btnBase, focusRing, iconSize[size], "rounded-full", idleClassName)}
>
<ChevronLeft />
</button>
{items.map((it, i) =>
it === "ellipsis" ? (
<span
key={`e${i}`}
aria-hidden="true"
className={cn(iconSize[size], "inline-flex items-center justify-center text-muted-foreground")}
>
<MoreHorizontal />
</span>
) : (
<button
key={it}
type="button"
aria-label={`Go to page ${it}`}
aria-current={it === current ? "page" : undefined}
onClick={() => goto(it)}
className={cn(
btnBase,
focusRing,
iconSize[size],
"rounded-full",
it === current ? "text-primary-foreground" : cn("text-foreground", idleClassName)
)}
>
{it === current && (
<motion.span
layoutId={reduce ? undefined : `${uid}-glass-active`}
aria-hidden="true"
transition={reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 500, damping: 34 }}
className={cn("absolute inset-0 rounded-full", activeClassName)}
/>
)}
<span className="relative">{it}</span>
</button>
)
)}
<button
type="button"
aria-label="Go to next page"
disabled={current >= n}
onClick={() => goto(current + 1)}
className={cn(btnBase, focusRing, iconSize[size], "rounded-full", idleClassName)}
>
<ChevronRight />
</button>
</nav>
)
}
/* Frost: a light blur + a thin edge. DEPTH sm (4px): the plainest container; behind
a short paginator bar anything more is an invisible cost. */
export function FrostPagination(props: PaginationProps) {
return (
<GlassShell
props={props}
shellClassName={cn(
glassDepth.sm,
"border border-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)]"
)}
activeClassName="bg-primary"
idleClassName="text-foreground hover:bg-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)]"
/>
)
}
/* Tint: an info-toned glass container. DEPTH md (8px, the canonical default): the character comes from the TONE, not the blur. Tint overrides both the bg-* and the supports-* step. */
export function TintPagination(props: PaginationProps) {
return (
<GlassShell
props={props}
shellClassName={cn(
glassDepth.md,
"border border-[color-mix(in_oklab,var(--color-info)_22%,transparent)]",
"bg-[color-mix(in_oklab,var(--color-info)_10%,transparent)]",
"supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-info)_10%,transparent)]"
)}
activeClassName="bg-primary"
idleClassName="text-foreground hover:bg-[color-mix(in_oklab,var(--color-info)_16%,transparent)]"
/>
)
}
/* Smoke: a smoky scrim. DEPTH md (8px): DELIBERATELY the same step as Tint - glass at the same distance, the difference being tone only (foreground smoke versus info). */
export function SmokePagination(props: PaginationProps) {
return (
<GlassShell
props={props}
shellClassName={cn(
glassDepth.md,
"border border-[color-mix(in_oklab,var(--color-foreground)_14%,transparent)]",
"bg-[color-mix(in_oklab,var(--color-foreground)_12%,transparent)]",
"supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-foreground)_12%,transparent)]"
)}
activeClassName="bg-primary"
idleClassName="text-foreground hover:bg-[color-mix(in_oklab,var(--color-foreground)_18%,transparent)]"
/>
)
}
/* Crystal: a clear pane. DEPTH lg (16px): diffusion is the SUBJECT here, not decoration - what is behind becomes texture, not text. The active page is now a solid bg-primary: the old color-mix(primary 88%) was semi-transparent and weakened contrast over the glass; the active item must be an OPAQUE emphasis above the bar. */
export function CrystalPagination(props: PaginationProps) {
return (
<GlassShell
props={props}
shellClassName={cn(
glassDepth.lg,
"border border-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)]",
"border-t-[color-mix(in_oklab,var(--color-background)_70%,transparent)]",
"supports-[backdrop-filter]:backdrop-saturate-150"
)}
activeClassName="bg-primary"
idleClassName="text-foreground hover:bg-[color-mix(in_oklab,var(--color-background)_60%,transparent)]"
/>
)
}
/* Depth: layered depth. DEPTH xl (24px): the ONLY variant that earns the top of the scale - it reads like a lifted overlay bar, and the further from the surface the more what is behind scatters. Note: the `shadow-lg` on the shell and the `shadow-md` on the active item were removed - the first would override the same CSS property as the glassDepth signature, the second was already creating a second depth layer above the bar. The body separation comes from a ring. */
export function DepthPagination(props: PaginationProps) {
return (
<GlassShell
props={props}
shellClassName={cn(
glassDepth.xl,
"border border-[color-mix(in_oklab,var(--color-foreground)_12%,transparent)]",
"bg-gradient-to-b supports-[backdrop-filter]:bg-transparent",
"from-[color-mix(in_oklab,var(--color-background)_70%,transparent)]",
"to-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)]",
"ring-1 ring-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)]"
)}
activeClassName="bg-primary"
idleClassName="text-foreground hover:bg-[color-mix(in_oklab,var(--color-background)_70%,transparent)]"
/>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Frost
A light backdrop-blur shell with a hairline edge.
import { FrostPagination } from "@/components/ui/pagination-glass"
<FrostPagination />Tint
A primary-tinted glass shell.
import { TintPagination } from "@/components/ui/pagination-glass"
<TintPagination />Smoke
A smoky scrim with a medium blur.
import { SmokePagination } from "@/components/ui/pagination-glass"
<SmokePagination />Crystal
A very clear shell with a strong blur and a bright top edge.
import { CrystalPagination } from "@/components/ui/pagination-glass"
<CrystalPagination />Depth
A gradient glass shell with a layered shadow.
import { DepthPagination } from "@/components/ui/pagination-glass"
<DepthPagination />ai2 Glass paginations: 5 styled variations on the token system
The ai2 Glass paginations are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around frosted page navigation controls. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion slides the active highlight between pages. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the highlight jumps to the active page with no animation.
What is in the ai2 Glass paginations?
5 exports in one file: Frost, Tint, Smoke, Crystal and Depth. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion slides the active highlight between pages.
- Reduced-motion aware: Under prefers-reduced-motion, the highlight jumps to the active page with no animation.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Glass paginations sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.