suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Patterns19
  • Overview
  • Action Bar
  • Action Menu
  • App Shell
  • Command Palette
  • Confirm
  • Context Actions
  • Date Picker
  • Flyout
  • Modal
  • Navbar
  • Paged List
  • Panel
  • Picker
  • Preview
  • Search Picker
  • Segment Picker
  • Split View
  • Table List
  • Trail

suiss/ui · Pattern

Paged List

A list that pages through items with numbered pagination on desktop and a Load more button on mobile.

Paged List is an open-source pattern from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

import { ChatCircleIcon } from "@phosphor-icons/react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export default function PagedListDemo() {
  return (
    <PagedList<Topic>
      items={topics}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
    />
  )
}

PagedList slices an array of items into pages and renders each one through renderItem inside a plain list. On desktop it shows a suiss/ui Pagination with previous and next links, the first and last page, the pages next to the current one, and ellipses for the gaps. The pagination is hidden when everything fits on one page.

On mobile (below 48rem, via useIsMobile), the list grows instead of paging: a full-width secondary Button appends the next page of items, and focus moves to the first newly loaded item. Use it for long feeds, search results or topic lists where the user moves through a known set of items.

Installation

CLI

$ pnpm dlx shadcn@latest add @esuiss/paged-list

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add patterns paged-list

Manual

  1. Add the cn helper.

  2. Add the pieces it builds on: Button, Pagination, use-mobile.

  3. Copy the source into components/patterns/paged-list.tsx:

components/patterns/paged-list.tsx
"use client"

import * as React from "react"
import { Button } from "@/components/ui/button"
import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
  1. Update the import paths to match your project.

Usage

import { PagedList } from "@/components/patterns/paged-list"
<PagedList<Topic> items={topics} pageSize={10} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} className="mx-auto w-full max-w-2xl" />
  • The current page is uncontrolled by default and starts at defaultPage. Pass page and onPageChange to control it.
  • Out-of-range page values are clamped between 1 and the page count.
  • Without getPageHref, page links use # and prevent navigation. With it, each link gets a real href and still updates the page on click.
  • Provide getKey when items have stable IDs; the default key is the item's index.
  • The mobile Load more state is internal and independent of page.

Examples

Start on later page

Opens on page 4 by setting defaultPage.

import { ChatCircleIcon } from "@phosphor-icons/react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export default function PagedListStartOnLaterPage() {
  return (
    <PagedList<Topic>
      items={topics}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
      defaultPage={4}
    />
  )
}

Single page

Renders every item without pagination when the items fit on one page.

import { ChatCircleIcon } from "@phosphor-icons/react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export default function PagedListSinglePage() {
  return (
    <PagedList<Topic>
      items={topics.slice(0, 6)}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
    />
  )
}

Page links

Gives each page link a real href through getPageHref.

import { ChatCircleIcon } from "@phosphor-icons/react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export default function PagedListPageLinks() {
  return (
    <PagedList<Topic>
      items={topics}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
      getPageHref={(page: number) => `#page-${page}`}
    />
  )
}

Custom labels

Replaces the previous, next and Load more labels.

import { ChatCircleIcon } from "@phosphor-icons/react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export default function PagedListCustomLabels() {
  return (
    <PagedList<Topic>
      items={topics}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
      loadMoreLabel="Show more topics"
      previousLabel="Newer"
      nextLabel="Older"
    />
  )
}

Controlled

Owns the current page in state with page and onPageChange.

import { ChatCircleIcon } from "@phosphor-icons/react"
import * as React from "react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

function ControlledExample(
  props: React.ComponentProps<typeof PagedList<Topic>>
) {
  const [page, setPage] = React.useState(2)

  return (
    <div className="flex flex-col gap-2">
      <PagedList {...props} page={page} onPageChange={setPage} />
      <p className="text-center text-sm text-muted-foreground">Page {page}</p>
    </div>
  )
}

export default function PagedListControlled() {
  return (
    <ControlledExample
      items={topics}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
    />
  )
}

API Reference

01

PagedList

The root that renders the visible items and the pagination or Load more control.

PropTypeDefaultDescription
itemsT[]RequiredThe full array of items to page through.
pageSizenumber10The number of items per page, and per Load more step on mobile.
pagenumber-The current page when controlled.
defaultPagenumber1The page shown first when uncontrolled.
onPageChange(page: number) => void-Called with the new page number when the user changes pages.
getPageHref(page: number) => string-Returns the href for a page link, turning pagination into real links.
getKey(item: T, index: number) => React.Key(_, index) => indexReturns the React key for an item.
renderItem(item: T, index: number) => React.ReactNodeRequiredRenders one item; receives the item and its index in items.
loadMoreLabelstring"Load more"The label of the mobile Load more button.
previousLabelstring-Visible text for the previous link; it also becomes its accessible name.
nextLabelstring-Visible text for the next link; it also becomes its accessible name.
listClassNamestring-Classes applied to the inner list element.

Also accepts the props of React.ComponentProps<"div">.

Accessibility

  • Items render in a ul, each inside its own li.
  • The desktop pagination is a navigation landmark; the current page link has aria-current="page".
  • Previous and next links get aria-disabled on the first and last page. Their default accessible names are Go to previous page and Go to next page.
  • Changing pages scrolls the list into view. Loading more on mobile moves focus to the first focusable element of the first new item.

Notes

Styling hooks: data-slot="paged-list", paged-list-items, paged-list-item, paged-list-pagination and paged-list-load-more. Because renderItem output is wrapped in an li, return row content such as an Item, not another list item.

iOS
Paged List on iOS in light modePaged List on iOS in dark mode
import { ChatCircleIcon } from "phosphor-react-native"
import { PagedList } from "@/components/patterns/paged-list"
import { Icon } from "@/components/ui/icon"
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item onPress={() => undefined}>
      <ItemMedia variant="icon">
        <Icon as={ChatCircleIcon} />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export function PagedListExample() {
  return (
    <PagedList<Topic> items={topics} pageSize={10} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PagedListExample: View {
  var body: some View {
    PagedListDemo(topics: pagedListTopics)
  }
}

struct PagedListTopic: Identifiable {
  let id: Int
  let title: String
  let replies: Int
}

let pagedListSubjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

let pagedListTopics: [PagedListTopic] = (0..<57).map { index in
  PagedListTopic(
    id: index + 1,
    title: "\(pagedListSubjects[index % pagedListSubjects.count]) · thread \(index + 1)",
    replies: (index * 7) % 23
  )
}

struct PagedListDemo: View {
  let topics: [PagedListTopic]
  var pageSize = 10
  var loadMoreLabel: LocalizedStringKey = "Load more"

  var body: some View {
    PagedList(topics, pageSize: pageSize, loadMoreLabel: loadMoreLabel) { topic, _ in
      Item(action: {}) {
        ItemMedia(.icon) {
          Ph.chatCircle.regular
        }
        ItemContent {
          ItemTitle(verbatim: topic.title)
          ItemDescription(verbatim: "\(topic.replies) replies")
        }
      }
    }
  }
}
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ChatCircle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemDescription
import com.esuiss.ui.components.ItemMedia
import com.esuiss.ui.components.ItemMediaVariant
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.patterns.PagedList

@Composable
fun PagedListExample() {
    PagedListDemo(pagedListTopics)
}

class PagedListTopic(val id: Int, val title: String, val replies: Int)

val pagedListSubjects = listOf(
    "Getting started",
    "Dark mode tokens",
    "Form validation",
    "Release notes",
    "Accessibility",
    "Theming",
    "Server components",
    "Animations",
)

val pagedListTopics = List(57) { index ->
    PagedListTopic(
        id = index + 1,
        title = "${pagedListSubjects[index % pagedListSubjects.size]} · thread ${index + 1}",
        replies = (index * 7) % 23,
    )
}

@Composable
fun PagedListDemo(items: List<PagedListTopic>, pageSize: Int = 10, loadMoreLabel: String = "Load more") {
    PagedList(
        items = items,
        modifier = Modifier.fillMaxWidth().height(560.dp),
        pageSize = pageSize,
        getKey = { topic, _ -> topic.id },
        loadMoreLabel = loadMoreLabel,
    ) { topic, _ ->
        PagedListRow(topic)
    }
}

@Composable
fun PagedListRow(topic: PagedListTopic) {
    Item(onClick = {}) {
        ItemMedia(variant = ItemMediaVariant.Icon) {
            Icon(PhosphorIcons.Regular.ChatCircle, contentDescription = null)
        }
        ItemContent {
            ItemTitle(topic.title)
            ItemDescription("${topic.replies} replies")
        }
    }
}

PagedList takes the full items array and renders only the first pageSize items. When the user scrolls near the end, or presses the Load more button in the footer, the next page is appended.

The button disappears once every item is shown. Use it for long local lists where rendering everything at once is not needed.

Installation

$ pnpm dlx shadcn@latest add @esuiss-rn/paged-list

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add paged-list
$ pnpm dlx @esuiss/ui@latest add paged-list --platform swift
$ pnpm dlx @esuiss/ui@latest add paged-list --platform kotlin

The CLI also adds what it builds on: Button, Text.

Manual

  1. Install the dependencies:
npx expo install cn
  1. Add the pieces it builds on: Button, Text.

  2. Copy the source into components/patterns/paged-list.tsx:

uniwind/components/patterns/paged-list.tsx
import { cn } from "cn"
import * as React from "react"
import { FlatList, type FlatListProps, View } from "react-native"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

function PagedList<T>({
  items,
  pageSize = 10,
  getKey = (_, index) => index,
  renderItem,
  loadMoreLabel = "Load more",
  className,
  listClassName,
  ...props
}: Omit<
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn
  1. Add the pieces it builds on: Button, Text.

  2. Copy the source into components/patterns/paged-list.tsx:

nativewind/components/patterns/paged-list.tsx
import { cn } from "cn"
import * as React from "react"
import { FlatList, type FlatListProps, View } from "react-native"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

function PagedList<T>({
  items,
  pageSize = 10,
  getKey = (_, index) => index,
  renderItem,
  loadMoreLabel = "Load more",
  className,
  listClassName,
  ...props
}: Omit<
  1. Update the import paths to match your project.
  1. Add the pieces it builds on: Button, Text.

  2. Copy the source into components/patterns/paged-list.tsx:

stylesheet/components/patterns/paged-list.tsx
import * as React from "react"
import {
  FlatList,
  type FlatListProps,
  type StyleProp,
  StyleSheet,
  View,
  type ViewStyle,
} from "react-native"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

const styles = StyleSheet.create({
  root: { flex: 1 },
  loadMore: { marginTop: 16 },
})
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Copy the theme files every view uses:

swift/Theme/Theme.swift
import SwiftUI
import UIKit

struct RGBA: Sendable {
  let red: CGFloat
  let green: CGFloat
  let blue: CGFloat
  let alpha: CGFloat

  init(_ hex: UInt32, _ alpha: CGFloat = 1) {
    red = CGFloat((hex >> 16) & 0xFF) / 255
    green = CGFloat((hex >> 8) & 0xFF) / 255
    blue = CGFloat(hex & 0xFF) / 255
    self.alpha = alpha
  }
swift/Theme/Tokens.swift
import SwiftUI

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source and every file it uses:
swift/Patterns/PagedList.swift
import SwiftUI

enum PagedListMath {
  static func pageCount(total: Int, pageSize: Int) -> Int {
    max(1, Int((Double(total) / Double(max(pageSize, 1))).rounded(.up)))
  }

  static func visibleCount(loaded: Int, pageSize: Int, total: Int) -> Int {
    min(loaded * max(pageSize, 1), total)
  }

  static func shouldLoad(appearing index: Int, visible: Int, total: Int) -> Bool {
    index == visible - 1 && visible < total
  }
}
swift/Components/Button.swift
import SwiftUI

public struct VariantButtonStyle: ButtonStyle {
  public enum Variant: CaseIterable, Sendable {
    case `default`, outline, secondary, tinted, ghost, plain, destructive, link
  }

  public enum Size: CaseIterable, Sendable {
    case `default`, xs, sm, lg, xl, icon, iconXs, iconSm, iconLg, iconXl

    var height: CGFloat {
      switch self {
      case .xs, .iconXs: 24
      case .sm, .iconSm: 32
      case .default, .icon: 36
      case .lg, .iconLg: 44
swift/Components/ButtonGroup.swift
import SwiftUI

extension EnvironmentValues {
  @Entry var buttonGroup: Axis? = nil
  @Entry var buttonGroupSize: CGSize = .zero
}

enum ButtonGroupSpace: Hashable {
  case group
}

struct ButtonGroupFrame {
  let bounds: Anchor<CGRect>
  let outlined: Bool
}
swift/Components/Item.swift
import SwiftUI

public enum ItemVariant: CaseIterable, Sendable {
  case `default`, outline, muted
}

public enum ItemSize: CaseIterable, Sendable {
  case `default`, sm, xs

  var horizontalPadding: CGFloat {
    switch self {
    case .default: 16
    case .sm: 14
    case .xs: 12
    }
  }
swift/Components/Separator.swift
import SwiftUI

public struct Separator: View {
  let orientation: Axis

  public init(_ orientation: Axis = .horizontal) {
    self.orientation = orientation
  }

  public var body: some View {
    Rectangle()
      .fill(EsuissColor.border)
      .frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
      .frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
      .accessibilityHidden(true)
  }
swift/Components/TextVariant.swift
import SwiftUI

public enum TextVariant: CaseIterable, Sendable {
  case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted

  var style: TextStyle {
    switch self {
    case .h1: .xl3
    case .h2: .xl2
    case .h3: .xl
    case .h4, .lead, .large: .lg
    case .code, .small, .muted: .sm
    default: .base
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
build.gradle.kts
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
kotlin/theme/Tokens.kt
package com.esuiss.ui.theme

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Immutable
data class Colors(
    val background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
kotlin/patterns/PagedList.kt
package com.esuiss.ui.patterns

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
kotlin/components/Button.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
kotlin/components/ButtonGroup.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
kotlin/components/Content.kt
package com.esuiss.ui.components

import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
kotlin/components/Icon.kt
package com.esuiss.ui.components

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun Icon(
    imageVector: ImageVector,
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { PagedList } from "@/components/patterns/paged-list"
<PagedList<Topic> items={topics} pageSize={10} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} />

Pass items, renderItem and a getKey that returns a stable key for each item. renderItem receives the item and its index, not the FlatList info object.

Other FlatList props pass through, except data, keyExtractor, onEndReached and ListFooterComponent, which the list manages. Use listClassName to style the content container.

Examples

Small pages

Uses a pageSize of 3 so pages load sooner.

iOS
Paged List (small pages) on iOS in light modePaged List (small pages) on iOS in dark mode
import { ChatCircleIcon } from "phosphor-react-native"
import { PagedList } from "@/components/patterns/paged-list"
import { Icon } from "@/components/ui/icon"
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item onPress={() => undefined}>
      <ItemMedia variant="icon">
        <Icon as={ChatCircleIcon} />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export function PagedListSmallPagesExample() {
  return (
    <PagedList<Topic> items={topics} pageSize={3} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PagedListSmallPagesExample: View {
  var body: some View {
    PagedListDemo(topics: pagedListTopics, pageSize: 3)
  }
}

struct PagedListTopic: Identifiable {
  let id: Int
  let title: String
  let replies: Int
}

let pagedListSubjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

let pagedListTopics: [PagedListTopic] = (0..<57).map { index in
  PagedListTopic(
    id: index + 1,
    title: "\(pagedListSubjects[index % pagedListSubjects.count]) · thread \(index + 1)",
    replies: (index * 7) % 23
  )
}

struct PagedListDemo: View {
  let topics: [PagedListTopic]
  var pageSize = 10
  var loadMoreLabel: LocalizedStringKey = "Load more"

  var body: some View {
    PagedList(topics, pageSize: pageSize, loadMoreLabel: loadMoreLabel) { topic, _ in
      Item(action: {}) {
        ItemMedia(.icon) {
          Ph.chatCircle.regular
        }
        ItemContent {
          ItemTitle(verbatim: topic.title)
          ItemDescription(verbatim: "\(topic.replies) replies")
        }
      }
    }
  }
}
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ChatCircle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemDescription
import com.esuiss.ui.components.ItemMedia
import com.esuiss.ui.components.ItemMediaVariant
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.patterns.PagedList

@Composable
fun PagedListSmallPagesExample() {
    PagedListDemo(pagedListTopics, pageSize = 3)
}

class PagedListTopic(val id: Int, val title: String, val replies: Int)

val pagedListSubjects = listOf(
    "Getting started",
    "Dark mode tokens",
    "Form validation",
    "Release notes",
    "Accessibility",
    "Theming",
    "Server components",
    "Animations",
)

val pagedListTopics = List(57) { index ->
    PagedListTopic(
        id = index + 1,
        title = "${pagedListSubjects[index % pagedListSubjects.size]} · thread ${index + 1}",
        replies = (index * 7) % 23,
    )
}

@Composable
fun PagedListDemo(items: List<PagedListTopic>, pageSize: Int = 10, loadMoreLabel: String = "Load more") {
    PagedList(
        items = items,
        modifier = Modifier.fillMaxWidth().height(560.dp),
        pageSize = pageSize,
        getKey = { topic, _ -> topic.id },
        loadMoreLabel = loadMoreLabel,
    ) { topic, _ ->
        PagedListRow(topic)
    }
}

@Composable
fun PagedListRow(topic: PagedListTopic) {
    Item(onClick = {}) {
        ItemMedia(variant = ItemMediaVariant.Icon) {
            Icon(PhosphorIcons.Regular.ChatCircle, contentDescription = null)
        }
        ItemContent {
            ItemTitle(topic.title)
            ItemDescription("${topic.replies} replies")
        }
    }
}

Single page

Fewer items than one page, so no Load more button appears.

iOS
Paged List (single page) on iOS in light modePaged List (single page) on iOS in dark mode
import { ChatCircleIcon } from "phosphor-react-native"
import { PagedList } from "@/components/patterns/paged-list"
import { Icon } from "@/components/ui/icon"
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item onPress={() => undefined}>
      <ItemMedia variant="icon">
        <Icon as={ChatCircleIcon} />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export function PagedListSinglePageExample() {
  return (
    <PagedList<Topic> items={topics.slice(0, 6)} pageSize={10} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PagedListSinglePageExample: View {
  var body: some View {
    PagedListDemo(topics: Array(pagedListTopics.prefix(6)))
  }
}

struct PagedListTopic: Identifiable {
  let id: Int
  let title: String
  let replies: Int
}

let pagedListSubjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

let pagedListTopics: [PagedListTopic] = (0..<57).map { index in
  PagedListTopic(
    id: index + 1,
    title: "\(pagedListSubjects[index % pagedListSubjects.count]) · thread \(index + 1)",
    replies: (index * 7) % 23
  )
}

struct PagedListDemo: View {
  let topics: [PagedListTopic]
  var pageSize = 10
  var loadMoreLabel: LocalizedStringKey = "Load more"

  var body: some View {
    PagedList(topics, pageSize: pageSize, loadMoreLabel: loadMoreLabel) { topic, _ in
      Item(action: {}) {
        ItemMedia(.icon) {
          Ph.chatCircle.regular
        }
        ItemContent {
          ItemTitle(verbatim: topic.title)
          ItemDescription(verbatim: "\(topic.replies) replies")
        }
      }
    }
  }
}
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ChatCircle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemDescription
import com.esuiss.ui.components.ItemMedia
import com.esuiss.ui.components.ItemMediaVariant
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.patterns.PagedList

@Composable
fun PagedListSinglePageExample() {
    PagedListDemo(pagedListTopics.take(6))
}

class PagedListTopic(val id: Int, val title: String, val replies: Int)

val pagedListSubjects = listOf(
    "Getting started",
    "Dark mode tokens",
    "Form validation",
    "Release notes",
    "Accessibility",
    "Theming",
    "Server components",
    "Animations",
)

val pagedListTopics = List(57) { index ->
    PagedListTopic(
        id = index + 1,
        title = "${pagedListSubjects[index % pagedListSubjects.size]} · thread ${index + 1}",
        replies = (index * 7) % 23,
    )
}

@Composable
fun PagedListDemo(items: List<PagedListTopic>, pageSize: Int = 10, loadMoreLabel: String = "Load more") {
    PagedList(
        items = items,
        modifier = Modifier.fillMaxWidth().height(560.dp),
        pageSize = pageSize,
        getKey = { topic, _ -> topic.id },
        loadMoreLabel = loadMoreLabel,
    ) { topic, _ ->
        PagedListRow(topic)
    }
}

@Composable
fun PagedListRow(topic: PagedListTopic) {
    Item(onClick = {}) {
        ItemMedia(variant = ItemMediaVariant.Icon) {
            Icon(PhosphorIcons.Regular.ChatCircle, contentDescription = null)
        }
        ItemContent {
            ItemTitle(topic.title)
            ItemDescription("${topic.replies} replies")
        }
    }
}

Custom labels

Renames the button with loadMoreLabel.

iOS
Paged List (custom labels) on iOS in light modePaged List (custom labels) on iOS in dark mode
import { ChatCircleIcon } from "phosphor-react-native"
import { PagedList } from "@/components/patterns/paged-list"
import { Icon } from "@/components/ui/icon"
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item onPress={() => undefined}>
      <ItemMedia variant="icon">
        <Icon as={ChatCircleIcon} />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export function PagedListCustomLabelsExample() {
  return (
    <PagedList<Topic> items={topics} pageSize={3} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} loadMoreLabel="Show more topics" />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PagedListCustomLabelsExample: View {
  var body: some View {
    PagedListDemo(topics: pagedListTopics, pageSize: 3, loadMoreLabel: "Show more topics")
  }
}

struct PagedListTopic: Identifiable {
  let id: Int
  let title: String
  let replies: Int
}

let pagedListSubjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

let pagedListTopics: [PagedListTopic] = (0..<57).map { index in
  PagedListTopic(
    id: index + 1,
    title: "\(pagedListSubjects[index % pagedListSubjects.count]) · thread \(index + 1)",
    replies: (index * 7) % 23
  )
}

struct PagedListDemo: View {
  let topics: [PagedListTopic]
  var pageSize = 10
  var loadMoreLabel: LocalizedStringKey = "Load more"

  var body: some View {
    PagedList(topics, pageSize: pageSize, loadMoreLabel: loadMoreLabel) { topic, _ in
      Item(action: {}) {
        ItemMedia(.icon) {
          Ph.chatCircle.regular
        }
        ItemContent {
          ItemTitle(verbatim: topic.title)
          ItemDescription(verbatim: "\(topic.replies) replies")
        }
      }
    }
  }
}
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ChatCircle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemDescription
import com.esuiss.ui.components.ItemMedia
import com.esuiss.ui.components.ItemMediaVariant
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.patterns.PagedList

@Composable
fun PagedListCustomLabelsExample() {
    PagedListDemo(pagedListTopics, pageSize = 3, loadMoreLabel = "Show more topics")
}

class PagedListTopic(val id: Int, val title: String, val replies: Int)

val pagedListSubjects = listOf(
    "Getting started",
    "Dark mode tokens",
    "Form validation",
    "Release notes",
    "Accessibility",
    "Theming",
    "Server components",
    "Animations",
)

val pagedListTopics = List(57) { index ->
    PagedListTopic(
        id = index + 1,
        title = "${pagedListSubjects[index % pagedListSubjects.size]} · thread ${index + 1}",
        replies = (index * 7) % 23,
    )
}

@Composable
fun PagedListDemo(items: List<PagedListTopic>, pageSize: Int = 10, loadMoreLabel: String = "Load more") {
    PagedList(
        items = items,
        modifier = Modifier.fillMaxWidth().height(560.dp),
        pageSize = pageSize,
        getKey = { topic, _ -> topic.id },
        loadMoreLabel = loadMoreLabel,
    ) { topic, _ ->
        PagedListRow(topic)
    }
}

@Composable
fun PagedListRow(topic: PagedListTopic) {
    Item(onClick = {}) {
        ItemMedia(variant = ItemMediaVariant.Icon) {
            Icon(PhosphorIcons.Regular.ChatCircle, contentDescription = null)
        }
        ItemContent {
            ItemTitle(topic.title)
            ItemDescription("${topic.replies} replies")
        }
    }
}

API Reference

01

PagedList

PropTypeDefaultDescription
itemsT[]RequiredThe full array of items.
pageSizenumber10Items added per page. Defaults to 10.
getKey(item: T, index: number) => React.Key(_, index) => indexReturns a unique key for an item. Defaults to the index.
renderItem(item: T, index: number) => React.ReactNodeRequiredRenders one item from the item and its index.
loadMoreLabelstring"Load more"Text of the footer button. Defaults to Load more.
classNamestring-Classes for the FlatList.
listClassNamestring-Classes for the list's content container.

Also accepts the props of FlatListProps<T>.

Accessibility

  • The Load more button is a Button, so screen reader users can load the next page without scrolling to the end.

Notes

  • The next page loads when the scroll position is within half a screen of the end (onEndReachedThreshold of 0.5).
  • The footer is a full-width secondary button.
NextPanelPreviousNavbar

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • Start on later page
    • Single page
    • Page links
    • Custom labels
    • Controlled
  4. 04API Reference
    • PagedList
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Small pages
    • Single page
    • Custom labels
  10. 10API Reference
    • PagedList
  11. 11Accessibility
  12. 12Notes