suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Interactions15
  • Overview
  • Card Stack
  • Image Viewer
  • Island
  • OTP Autofill
  • Preview Menu
  • Pull to Refresh
  • Reorder List
  • Search Reveal
  • Section Index
  • Selection List
  • Stepper
  • Swipe Actions
  • Tip
  • Top Banner
  • Undo Bar

suiss/ui · Interaction

Section Index

An A to Z index bar for jumping between sections of a long list by tapping, dragging, or typing.

Section Index is an open-source interaction 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 * as React from "react"
import {
  SectionIndex,
  SectionIndexBar,
  SectionIndexHeader,
  SectionIndexSection,
} from "@/components/interactions/section-index"
import { ScrollArea } from "@/components/ui/scroll-area"

const UPPERCASE = /[A-Z]/

const names = [
  "Aaron Price",
  "Abby Chen",
  "Alex Morgan",
  "Amira Haddad",
  "Ben Carter",
  "Bianca Rossi",
  "Carla Diaz",
  "Chris Evans",
  "Cody Brooks",
  "Dana Scott",
  "Derek Hale",
  "Elena Petrova",
  "Ethan Wright",
  "Fatima Khan",
  "Felix Wagner",
  "Grace Kim",
  "Hana Sato",
  "Henry Ford",
  "Ivy Turner",
  "Jack Miller",
  "Jade Wilson",
  "Jonas Berg",
  "Kai Nakamura",
  "Laura Bell",
  "Leo Martin",
  "Lina Costa",
  "Maya Patel",
  "Mia Novak",
  "Noah Fischer",
  "Nora Lind",
  "Omar Aziz",
  "Paula Silva",
  "Quinn Avery",
  "Rosa Lopez",
  "Ryan Cole",
  "Sara Ahmed",
  "Sofia Greco",
  "Tom Baker",
  "Uma Rao",
  "Victor Hugo",
  "Will Jensen",
  "Yara Nasser",
  "Zoe Adams",
  "8 Ball Club",
]

function groupByLetter() {
  const groups = new Map<string, string[]>()
  for (const name of names) {
    const first = name.charAt(0).toUpperCase()
    const letter = UPPERCASE.test(first) ? first : "#"
    groups.set(letter, [...(groups.get(letter) ?? []), name])
  }
  return [...groups.entries()].sort(([a], [b]) => {
    if (a === "#") return 1
    if (b === "#") return -1
    return a.localeCompare(b)
  })
}

function ContactsExample({ className }: { className: string }) {
  const [log, setLog] = React.useState("Tap or drag the letters.")

  return (
    <div className="mx-auto flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-3">
      <SectionIndex className={className}>
        <ScrollArea className="h-full rounded-2xl border [clip-path:inset(0_round_var(--radius-2xl))]">
          {groupByLetter().map(([letter, group]) => (
            <SectionIndexSection key={letter} value={letter}>
              <SectionIndexHeader>{letter}</SectionIndexHeader>
              <ul>
                {group.map((name) => (
                  <li
                    key={name}
                    className="ms-4 me-10 border-border not-first:border-t"
                  >
                    <button
                      type="button"
                      onClick={() => setLog(`Opened ${name}`)}
                      className="w-full py-3 text-start text-base outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
                    >
                      {name}
                    </button>
                  </li>
                ))}
              </ul>
            </SectionIndexSection>
          ))}
        </ScrollArea>
        <SectionIndexBar
          onValueChange={(letter) => setLog(`Jumped to ${letter}`)}
        />
      </SectionIndex>
      <p role="status" className="text-sm text-muted-foreground">
        {log}
      </p>
    </div>
  )
}

export default function SectionIndexDemo() {
  return <ContactsExample className="h-[min(36rem,calc(100dvh-7rem))]" />
}

SectionIndex recreates the iOS list index. Sections marked with SectionIndexSection carry sticky SectionIndexHeader titles, and SectionIndexBar sits along the trailing edge. Tapping or dragging over the bar scrolls the nearest scrollable ancestor so the matching section is at the top; while dragging, a bubble shows the current letter. If a letter has no section, it jumps to the next one that exists.

When the container is too short to fit every letter, the bar collapses to fewer letters separated by dots, while still mapping the full alphabet across its height. Use it for long alphabetized lists such as contacts.

Installation

CLI

$ pnpm dlx shadcn@latest add @esuiss/section-index

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 interactions section-index

Manual

  1. Add the cn helper.

  2. Copy the source into components/interactions/section-index.tsx:

components/interactions/section-index.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const LETTERS = [..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "#"]
const ROW_HEIGHT = 16
const PAGE = 5

function scrollParent(element: HTMLElement) {
  for (let node = element.parentElement; node; node = node.parentElement) {
    const { overflowY } = getComputedStyle(node)
    if (
      (overflowY === "auto" || overflowY === "scroll") &&
      node.scrollHeight > node.clientHeight
    )
  1. Update the import paths to match your project.

Usage

import { SectionIndex, SectionIndexSection, SectionIndexHeader, SectionIndexBar } from "@/components/interactions/section-index"
<ContactsExample className="h-[min(36rem,calc(100dvh-7rem))]" />
  • Wrap the scrolling list and SectionIndexBar in SectionIndex, which positions the bar. The list can scroll in its own container, such as ScrollArea, or in the page.
  • Set value on each SectionIndexSection to a letter from letters so the bar can find it.
  • Use onValueChange to react to jumps.

Examples

Compact

In a short list, the bar collapses its letters with dots and still jumps to any section.

import * as React from "react"
import {
  SectionIndex,
  SectionIndexBar,
  SectionIndexHeader,
  SectionIndexSection,
} from "@/components/interactions/section-index"
import { ScrollArea } from "@/components/ui/scroll-area"

const UPPERCASE = /[A-Z]/

const names = [
  "Aaron Price",
  "Abby Chen",
  "Alex Morgan",
  "Amira Haddad",
  "Ben Carter",
  "Bianca Rossi",
  "Carla Diaz",
  "Chris Evans",
  "Cody Brooks",
  "Dana Scott",
  "Derek Hale",
  "Elena Petrova",
  "Ethan Wright",
  "Fatima Khan",
  "Felix Wagner",
  "Grace Kim",
  "Hana Sato",
  "Henry Ford",
  "Ivy Turner",
  "Jack Miller",
  "Jade Wilson",
  "Jonas Berg",
  "Kai Nakamura",
  "Laura Bell",
  "Leo Martin",
  "Lina Costa",
  "Maya Patel",
  "Mia Novak",
  "Noah Fischer",
  "Nora Lind",
  "Omar Aziz",
  "Paula Silva",
  "Quinn Avery",
  "Rosa Lopez",
  "Ryan Cole",
  "Sara Ahmed",
  "Sofia Greco",
  "Tom Baker",
  "Uma Rao",
  "Victor Hugo",
  "Will Jensen",
  "Yara Nasser",
  "Zoe Adams",
  "8 Ball Club",
]

function groupByLetter() {
  const groups = new Map<string, string[]>()
  for (const name of names) {
    const first = name.charAt(0).toUpperCase()
    const letter = UPPERCASE.test(first) ? first : "#"
    groups.set(letter, [...(groups.get(letter) ?? []), name])
  }
  return [...groups.entries()].sort(([a], [b]) => {
    if (a === "#") return 1
    if (b === "#") return -1
    return a.localeCompare(b)
  })
}

function ContactsExample({ className }: { className: string }) {
  const [log, setLog] = React.useState("Tap or drag the letters.")

  return (
    <div className="mx-auto flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-3">
      <SectionIndex className={className}>
        <ScrollArea className="h-full rounded-2xl border [clip-path:inset(0_round_var(--radius-2xl))]">
          {groupByLetter().map(([letter, group]) => (
            <SectionIndexSection key={letter} value={letter}>
              <SectionIndexHeader>{letter}</SectionIndexHeader>
              <ul>
                {group.map((name) => (
                  <li
                    key={name}
                    className="ms-4 me-10 border-border not-first:border-t"
                  >
                    <button
                      type="button"
                      onClick={() => setLog(`Opened ${name}`)}
                      className="w-full py-3 text-start text-base outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
                    >
                      {name}
                    </button>
                  </li>
                ))}
              </ul>
            </SectionIndexSection>
          ))}
        </ScrollArea>
        <SectionIndexBar
          onValueChange={(letter) => setLog(`Jumped to ${letter}`)}
        />
      </SectionIndex>
      <p role="status" className="text-sm text-muted-foreground">
        {log}
      </p>
    </div>
  )
}

export default function SectionIndexCompact() {
  return <ContactsExample className="h-64" />
}

API Reference

01

SectionIndex

The relative container that holds the list and the index bar.

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

02

SectionIndexSection

A section of the list identified by a letter.

PropTypeDefaultDescription
valuestringRequiredThe letter this section belongs to.

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

03

SectionIndexHeader

The sticky title at the top of a section.

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

04

SectionIndexBar

The vertical letter bar that jumps between sections.

PropTypeDefaultDescription
lettersstring[]LETTERSThe letters shown on the bar; defaults to A through Z followed by #.
onValueChange(value: string) => void-Called with the letter whenever the bar jumps to a section.

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

Accessibility

  • The bar is a vertical slider labelled Section index, with aria-valuetext set to the current letter.
  • Arrow keys move one letter, PageUp and PageDown move five, and Home and End reach the first and last letters.
  • Typing a letter jumps directly to it.
  • The visible letters and drag bubble are aria-hidden.

Notes

  • Parts use data-slot values section-index, section-index-section, section-index-header, section-index-bar, and section-index-bubble. Sections expose data-value, and the bar sets data-dragging during a drag.
  • The bar extends its hit area toward the list so it is easier to grab.
iOS
Section Index on iOS in light modeSection Index on iOS in dark mode
import * as React from "react"
import { Pressable, View } from "react-native"
import { SectionIndex, SectionIndexBar, SectionIndexHeader, SectionIndexScrollView, SectionIndexSection } from "@/components/interactions/section-index"
import { Text } from "@/components/ui/text"

const UPPERCASE = /[A-Z]/

const names = [
  "Aaron Price",
  "Abby Chen",
  "Alex Morgan",
  "Amira Haddad",
  "Ben Carter",
  "Bianca Rossi",
  "Carla Diaz",
  "Chris Evans",
  "Cody Brooks",
  "Dana Scott",
  "Derek Hale",
  "Elena Petrova",
  "Ethan Wright",
  "Fatima Khan",
  "Felix Wagner",
  "Grace Kim",
  "Hana Sato",
  "Henry Ford",
  "Ivy Turner",
  "Jack Miller",
  "Jade Wilson",
  "Jonas Berg",
  "Kai Nakamura",
  "Laura Bell",
  "Leo Martin",
  "Lina Costa",
  "Maya Patel",
  "Mia Novak",
  "Noah Fischer",
  "Nora Lind",
  "Omar Aziz",
  "Paula Silva",
  "Quinn Avery",
  "Rosa Lopez",
  "Ryan Cole",
  "Sara Ahmed",
  "Sofia Greco",
  "Tom Baker",
  "Uma Rao",
  "Victor Hugo",
  "Will Jensen",
  "Yara Nasser",
  "Zoe Adams",
  "8 Ball Club",
]

function groupByLetter() {
  const groups = new Map<string, string[]>()
  for (const name of names) {
    const first = name.charAt(0).toUpperCase()
    const letter = UPPERCASE.test(first) ? first : "#"
    groups.set(letter, [...(groups.get(letter) ?? []), name])
  }
  return [...groups.entries()].sort(([a], [b]) => {
    if (a === "#") return 1
    if (b === "#") return -1
    return a.localeCompare(b)
  })
}

function ContactsExample({ className }: { className: string }) {
  const [log, setLog] = React.useState("Tap or drag the letters.")
  return (
    <View className="gap-3">
      <SectionIndex className={className}>
        <SectionIndexScrollView className="rounded-2xl border border-border">
          {groupByLetter().map(([letter, group]) => (
            <SectionIndexSection key={letter} value={letter}>
              <SectionIndexHeader>{letter}</SectionIndexHeader>
              {group.map((name, index) => (
                <Pressable
                  key={name}
                  onPress={() => setLog(`Opened ${name}`)}
                  className={`ms-4 me-10 py-3 active:opacity-60 ${index > 0 ? "border-t border-border" : ""}`}
                >
                  <Text>{name}</Text>
                </Pressable>
              ))}
            </SectionIndexSection>
          ))}
        </SectionIndexScrollView>
        <SectionIndexBar
          onValueChange={(letter) => setLog(`Jumped to ${letter}`)}
        />
      </SectionIndex>
      <Text variant="muted">{log}</Text>
    </View>
  )
}

export function SectionIndexExample() {
  return (
    <ContactsExample className="h-[520px]" />
  )
}
import EsuissUI
import SwiftUI

struct SectionIndexExample: View {
  var body: some View {
    SectionIndexContacts(height: 520)
  }
}

let sectionIndexNames = [
  "Aaron Price", "Abby Chen", "Alex Morgan", "Amira Haddad", "Ben Carter", "Bianca Rossi",
  "Carla Diaz", "Chris Evans", "Cody Brooks", "Dana Scott", "Derek Hale", "Elena Petrova",
  "Ethan Wright", "Fatima Khan", "Felix Wagner", "Grace Kim", "Hana Sato", "Henry Ford",
  "Ivy Turner", "Jack Miller", "Jade Wilson", "Jonas Berg", "Kai Nakamura", "Laura Bell",
  "Leo Martin", "Lina Costa", "Maya Patel", "Mia Novak", "Noah Fischer", "Nora Lind",
  "Omar Aziz", "Paula Silva", "Quinn Avery", "Rosa Lopez", "Ryan Cole", "Sara Ahmed",
  "Sofia Greco", "Tom Baker", "Uma Rao", "Victor Hugo", "Will Jensen", "Yara Nasser",
  "Zoe Adams", "8 Ball Club",
]

let sectionIndexGroups: [(String, [String])] = {
  var groups: [String: [String]] = [:]
  for name in sectionIndexNames {
    let first = name.prefix(1).uppercased()
    let letter = first.range(of: "^[A-Z]$", options: .regularExpression) != nil ? first : "#"
    groups[letter, default: []].append(name)
  }
  return groups.sorted { lhs, rhs in
    if lhs.key == "#" { return false }
    if rhs.key == "#" { return true }
    return lhs.key < rhs.key
  }
  .map { ($0.key, $0.value) }
}()

struct SectionIndexContacts: View {
  let height: CGFloat

  @State private var log: LocalizedStringKey = "Tap or drag the letters."

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 12) {
      SectionIndex {
        ScrollView {
          LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) {
            ForEach(sectionIndexGroups, id: \.0) { entry in
              SectionIndexSection(entry.0) {
                ForEach(Array(entry.1.enumerated()), id: \.element) { index, name in
                  Button {
                    log = "Opened \(name)"
                  } label: {
                    Text(verbatim: name)
                      .textStyle(.base)
                      .foregroundStyle(EsuissColor.foreground)
                      .padding(.vertical, 12)
                      .frame(maxWidth: .infinity, alignment: .leading)
                      .contentShape(Rectangle())
                  }
                  .buttonStyle(SectionIndexRowStyle())
                  .overlay(alignment: .top) {
                    if index > 0 {
                      EsuissColor.border.frame(height: 1)
                    }
                  }
                  .padding(.leading, 16)
                  .padding(.trailing, 40)
                }
              }
            }
          }
        }
        .clipShape(shape)
        .overlay { shape.strokeBorder(EsuissColor.border) }
        SectionIndexBar { letter in
          log = "Jumped to \(letter)"
        }
      }
      .frame(height: height)
      Text(log)
        .textVariant(.muted)
    }
  }
}

struct SectionIndexRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.SectionIndex
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius

@Composable
fun SectionIndexExample() {
    SectionIndexContacts(height = 520.dp)
}

val sectionIndexNames = listOf(
    "Aaron Price",
    "Abby Chen",
    "Alex Morgan",
    "Amira Haddad",
    "Ben Carter",
    "Bianca Rossi",
    "Carla Diaz",
    "Chris Evans",
    "Cody Brooks",
    "Dana Scott",
    "Derek Hale",
    "Elena Petrova",
    "Ethan Wright",
    "Fatima Khan",
    "Felix Wagner",
    "Grace Kim",
    "Hana Sato",
    "Henry Ford",
    "Ivy Turner",
    "Jack Miller",
    "Jade Wilson",
    "Jonas Berg",
    "Kai Nakamura",
    "Laura Bell",
    "Leo Martin",
    "Lina Costa",
    "Maya Patel",
    "Mia Novak",
    "Noah Fischer",
    "Nora Lind",
    "Omar Aziz",
    "Paula Silva",
    "Quinn Avery",
    "Rosa Lopez",
    "Ryan Cole",
    "Sara Ahmed",
    "Sofia Greco",
    "Tom Baker",
    "Uma Rao",
    "Victor Hugo",
    "Will Jensen",
    "Yara Nasser",
    "Zoe Adams",
    "8 Ball Club",
)

val sectionIndexGroups: List<Pair<String, List<String>>> =
    sectionIndexNames
        .groupBy { name ->
            val first = name.first().uppercaseChar()
            if (first in 'A'..'Z') first.toString() else "#"
        }
        .toList()
        .sortedWith(compareBy({ it.first == "#" }, { it.first }))

@Composable
fun SectionIndexContacts(height: Dp) {
    val colors = EsuissTheme.colors
    var log by rememberSaveable { mutableStateOf("Tap or drag the letters.") }
    val shape = RoundedCornerShape(Radius.xl2)
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        SectionIndex(
            modifier = Modifier
                .fillMaxWidth()
                .height(height)
                .clip(shape)
                .border(1.dp, colors.border, shape),
            onValueChange = { letter -> log = "Jumped to $letter" },
        ) {
            sectionIndexGroups.forEach { (letter, group) ->
                SectionIndexSection(letter) {
                    items(group.size, key = { group[it] }) { index ->
                        SectionIndexRow(group[index], divided = index > 0) { log = "Opened ${group[index]}" }
                    }
                }
            }
        }
        Text(log, variant = TextVariant.Muted)
    }
}

@Composable
fun SectionIndexRow(name: String, divided: Boolean, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column(
        Modifier
            .fillMaxWidth()
            .padding(start = 16.dp, end = 40.dp)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick),
    ) {
        if (divided) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
        Text(name, modifier = Modifier.alpha(if (pressed) 0.6f else 1f).padding(vertical = 12.dp))
    }
}

SectionIndex pairs a scroll view of lettered sections with SectionIndexBar, a column of letters on the end edge. Tapping a letter scrolls to that section; dragging along the bar scrubs through sections and shows the current letter in a large bubble beside the finger.

When the bar is too short for every letter, it shows a subset with • between them. Use it for long alphabetical lists such as contacts.

Installation

$ pnpm dlx shadcn@latest add @esuiss-rn/section-index

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

$ pnpm dlx @esuiss/ui@latest add section-index
$ pnpm dlx @esuiss/ui@latest add section-index --platform swift
$ pnpm dlx @esuiss/ui@latest add section-index --platform kotlin

The CLI also adds what it builds on: Text. On React Native it also adds the animated view components/ui/animated-view.tsx.

Manual

  1. Install the dependencies:
npx expo install cn react-native-reanimated react-native-worklets
  1. Add the pieces it builds on: Text, and the primitives components/primitives/section-index.tsx, components/primitives/use-render.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

uniwind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/section-index.tsx:
uniwind/components/interactions/section-index.tsx
import { cn } from "cn"
import type * as React from "react"
import { View } from "react-native"
import { FadeOut, ReduceMotion, ZoomIn } from "react-native-reanimated"
import * as SectionIndexPrimitive from "@/components/primitives/section-index"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Text } from "@/components/ui/text"

function SectionIndex({
  className,
  ...props
}: React.ComponentProps<typeof SectionIndexPrimitive.Root>) {
  return (
    <SectionIndexPrimitive.Root
      className={(state) => cn("relative", resolveClassName(className, state))}
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn react-native-reanimated react-native-worklets
  1. Add the pieces it builds on: Text, and the primitives components/primitives/section-index.tsx, components/primitives/use-render.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

nativewind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/section-index.tsx:
nativewind/components/interactions/section-index.tsx
import { cn } from "cn"
import type * as React from "react"
import { View } from "react-native"
import { FadeOut, ReduceMotion, ZoomIn } from "react-native-reanimated"
import * as SectionIndexPrimitive from "@/components/primitives/section-index"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Text } from "@/components/ui/text"

function SectionIndex({
  className,
  ...props
}: React.ComponentProps<typeof SectionIndexPrimitive.Root>) {
  return (
    <SectionIndexPrimitive.Root
      className={(state) => cn("relative", resolveClassName(className, state))}
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install react-native-reanimated react-native-worklets
  1. Add the pieces it builds on: Text, and the primitives components/primitives/section-index.tsx, components/primitives/use-render.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

stylesheet/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/section-index.tsx:
stylesheet/components/interactions/section-index.tsx
import type * as React from "react"
import { StyleSheet, View, type ViewStyle } from "react-native"
import { FadeOut, ReduceMotion, ZoomIn } from "react-native-reanimated"
import * as SectionIndexPrimitive from "@/components/primitives/section-index"
import { resolveStyle } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Text } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"

const styles = StyleSheet.create({
  root: { position: "relative" },
  scroll: { flex: 1 },
  header: { paddingHorizontal: 16, paddingVertical: 6 },
  headerText: { ...fontSize.sm, fontWeight: "600" },
  barFrame: {
    position: "absolute",
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Add the Swift package to your app:

Package.swift
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
  1. 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/Interactions/SectionIndex.swift
import SwiftUI

struct SectionIndexItem: Equatable, Sendable {
  let key: String
  let label: String
}

enum SectionIndexMath {
  static let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".map(String.init) + ["#"]
  static let rowHeight: CGFloat = 16

  static func capacity(height: CGFloat) -> Int {
    max(0, Int(((height - rowHeight) / rowHeight).rounded(.down)))
  }

  static func displayItems(_ letters: [String], capacity: Int) -> [SectionIndexItem] {
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/Checkbox.swift
import PhosphorSwift
import SwiftUI

extension Shadow {
  static let control = Shadow(color: .black.opacity(0.08), radius: 0.5, y: 0.5)
  static let thumb = Shadow(color: .black.opacity(0.12), radius: 1.5, y: 1)
}

struct SelectionIndicator<Mark: View>: View {
  let checked: Bool
  let pressed: Bool
  let cornerRadius: CGFloat
  let mark: Mark

  @Environment(\.isEnabled) private var isEnabled
  @Environment(\.isInvalid) private var invalid
swift/Components/Field.swift
import SwiftUI

extension EnvironmentValues {
  @Entry public var isInvalid = false
  @Entry var fieldInset = false
  @Entry var fieldGroupInset = false
  @Entry var fieldSetInset = false
}

extension View {
  public func invalid(_ isInvalid: Bool = true) -> some View {
    environment(\.isInvalid, isInvalid)
  }
}

public enum FieldOrientation: CaseIterable, Sendable {
swift/Components/Haptics.swift
import SwiftUI

extension EnvironmentValues {
  @Entry public var hapticsEnabled = true
}

extension View {
  public func haptics(_ enabled: Bool) -> some View {
    environment(\.hapticsEnabled, enabled)
  }

  func hapticFeedback<Trigger: Equatable>(_ feedback: SensoryFeedback, trigger: Trigger) -> some View {
    modifier(HapticFeedbackModifier(feedback: feedback, trigger: trigger) { _, _ in true })
  }

  func hapticFeedback<Trigger: Equatable>(
swift/Components/Header.swift
import PhosphorSwift
import SwiftUI

extension EnvironmentValues {
  @Entry var headerProgress: CGFloat?
  @Entry var headerReport: ((CGFloat) -> Void)?
}

enum HeaderMath {
  static func progress(minY: CGFloat, height: CGFloat) -> CGFloat {
    guard height > 0 else { return 0 }
    return min(max(-minY / height, 0), 1)
  }
}

public struct Header<Title: View, Leading: View, Trailing: View>: View {
swift/Components/KeyboardScrollView.swift
import SwiftUI
import UIKit

public struct KeyboardScrollView<Content: View>: View {
  let showsIndicators: Bool
  let content: Content

  public init(showsIndicators: Bool = true, @ViewBuilder content: () -> Content) {
    self.showsIndicators = showsIndicators
    self.content = content()
  }

  public var body: some View {
    ScrollView(.vertical, showsIndicators: showsIndicators) {
      content
        .frame(maxWidth: .infinity, alignment: .topLeading)
swift/Components/Screen.swift
import SwiftUI

extension EnvironmentValues {
  @Entry var screenBackground: Color = EsuissColor.background
}

public struct Screen<Content: View>: View {
  public enum Background: CaseIterable, Sendable {
    case background, muted

    var color: Color {
      switch self {
      case .background: EsuissColor.background
      case .muted: EsuissColor.muted
      }
    }
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/interactions/SectionIndex.kt
package com.esuiss.ui.interactions

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
kotlin/components/Haptics.kt
package com.esuiss.ui.components

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

val LocalHapticsEnabled = staticCompositionLocalOf { true }

@Composable
fun ProvideHaptics(enabled: Boolean, content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalHapticsEnabled provides enabled, content = content)
}
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { SectionIndex, SectionIndexScrollView, SectionIndexSection, SectionIndexHeader, SectionIndexBar } from "@/components/interactions/section-index"
<ContactsExample className="h-[520px]" />

Put a SectionIndexScrollView and a SectionIndexBar inside SectionIndex. Wrap each group in a SectionIndexSection with its letter as value, and start it with a SectionIndexHeader.

The bar shows A to Z and # by default; pass letters to match your sections. onValueChange is called with the letter when the user jumps.

Examples

Compact

A short list where the bar collapses letters into dots.

iOS
Section Index (compact) on iOS in light modeSection Index (compact) on iOS in dark mode
import * as React from "react"
import { Pressable, View } from "react-native"
import { SectionIndex, SectionIndexBar, SectionIndexHeader, SectionIndexScrollView, SectionIndexSection } from "@/components/interactions/section-index"
import { Text } from "@/components/ui/text"

const UPPERCASE = /[A-Z]/

const names = [
  "Aaron Price",
  "Abby Chen",
  "Alex Morgan",
  "Amira Haddad",
  "Ben Carter",
  "Bianca Rossi",
  "Carla Diaz",
  "Chris Evans",
  "Cody Brooks",
  "Dana Scott",
  "Derek Hale",
  "Elena Petrova",
  "Ethan Wright",
  "Fatima Khan",
  "Felix Wagner",
  "Grace Kim",
  "Hana Sato",
  "Henry Ford",
  "Ivy Turner",
  "Jack Miller",
  "Jade Wilson",
  "Jonas Berg",
  "Kai Nakamura",
  "Laura Bell",
  "Leo Martin",
  "Lina Costa",
  "Maya Patel",
  "Mia Novak",
  "Noah Fischer",
  "Nora Lind",
  "Omar Aziz",
  "Paula Silva",
  "Quinn Avery",
  "Rosa Lopez",
  "Ryan Cole",
  "Sara Ahmed",
  "Sofia Greco",
  "Tom Baker",
  "Uma Rao",
  "Victor Hugo",
  "Will Jensen",
  "Yara Nasser",
  "Zoe Adams",
  "8 Ball Club",
]

function groupByLetter() {
  const groups = new Map<string, string[]>()
  for (const name of names) {
    const first = name.charAt(0).toUpperCase()
    const letter = UPPERCASE.test(first) ? first : "#"
    groups.set(letter, [...(groups.get(letter) ?? []), name])
  }
  return [...groups.entries()].sort(([a], [b]) => {
    if (a === "#") return 1
    if (b === "#") return -1
    return a.localeCompare(b)
  })
}

function ContactsExample({ className }: { className: string }) {
  const [log, setLog] = React.useState("Tap or drag the letters.")
  return (
    <View className="gap-3">
      <SectionIndex className={className}>
        <SectionIndexScrollView className="rounded-2xl border border-border">
          {groupByLetter().map(([letter, group]) => (
            <SectionIndexSection key={letter} value={letter}>
              <SectionIndexHeader>{letter}</SectionIndexHeader>
              {group.map((name, index) => (
                <Pressable
                  key={name}
                  onPress={() => setLog(`Opened ${name}`)}
                  className={`ms-4 me-10 py-3 active:opacity-60 ${index > 0 ? "border-t border-border" : ""}`}
                >
                  <Text>{name}</Text>
                </Pressable>
              ))}
            </SectionIndexSection>
          ))}
        </SectionIndexScrollView>
        <SectionIndexBar
          onValueChange={(letter) => setLog(`Jumped to ${letter}`)}
        />
      </SectionIndex>
      <Text variant="muted">{log}</Text>
    </View>
  )
}

export function SectionIndexCompactExample() {
  return (
    <ContactsExample className="h-64" />
  )
}
import EsuissUI
import SwiftUI

struct SectionIndexCompactExample: View {
  var body: some View {
    SectionIndexContacts(height: 256)
  }
}

let sectionIndexNames = [
  "Aaron Price", "Abby Chen", "Alex Morgan", "Amira Haddad", "Ben Carter", "Bianca Rossi",
  "Carla Diaz", "Chris Evans", "Cody Brooks", "Dana Scott", "Derek Hale", "Elena Petrova",
  "Ethan Wright", "Fatima Khan", "Felix Wagner", "Grace Kim", "Hana Sato", "Henry Ford",
  "Ivy Turner", "Jack Miller", "Jade Wilson", "Jonas Berg", "Kai Nakamura", "Laura Bell",
  "Leo Martin", "Lina Costa", "Maya Patel", "Mia Novak", "Noah Fischer", "Nora Lind",
  "Omar Aziz", "Paula Silva", "Quinn Avery", "Rosa Lopez", "Ryan Cole", "Sara Ahmed",
  "Sofia Greco", "Tom Baker", "Uma Rao", "Victor Hugo", "Will Jensen", "Yara Nasser",
  "Zoe Adams", "8 Ball Club",
]

let sectionIndexGroups: [(String, [String])] = {
  var groups: [String: [String]] = [:]
  for name in sectionIndexNames {
    let first = name.prefix(1).uppercased()
    let letter = first.range(of: "^[A-Z]$", options: .regularExpression) != nil ? first : "#"
    groups[letter, default: []].append(name)
  }
  return groups.sorted { lhs, rhs in
    if lhs.key == "#" { return false }
    if rhs.key == "#" { return true }
    return lhs.key < rhs.key
  }
  .map { ($0.key, $0.value) }
}()

struct SectionIndexContacts: View {
  let height: CGFloat

  @State private var log: LocalizedStringKey = "Tap or drag the letters."

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 12) {
      SectionIndex {
        ScrollView {
          LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) {
            ForEach(sectionIndexGroups, id: \.0) { entry in
              SectionIndexSection(entry.0) {
                ForEach(Array(entry.1.enumerated()), id: \.element) { index, name in
                  Button {
                    log = "Opened \(name)"
                  } label: {
                    Text(verbatim: name)
                      .textStyle(.base)
                      .foregroundStyle(EsuissColor.foreground)
                      .padding(.vertical, 12)
                      .frame(maxWidth: .infinity, alignment: .leading)
                      .contentShape(Rectangle())
                  }
                  .buttonStyle(SectionIndexRowStyle())
                  .overlay(alignment: .top) {
                    if index > 0 {
                      EsuissColor.border.frame(height: 1)
                    }
                  }
                  .padding(.leading, 16)
                  .padding(.trailing, 40)
                }
              }
            }
          }
        }
        .clipShape(shape)
        .overlay { shape.strokeBorder(EsuissColor.border) }
        SectionIndexBar { letter in
          log = "Jumped to \(letter)"
        }
      }
      .frame(height: height)
      Text(log)
        .textVariant(.muted)
    }
  }
}

struct SectionIndexRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.SectionIndex
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius

@Composable
fun SectionIndexCompactExample() {
    SectionIndexContacts(height = 256.dp)
}

val sectionIndexNames = listOf(
    "Aaron Price",
    "Abby Chen",
    "Alex Morgan",
    "Amira Haddad",
    "Ben Carter",
    "Bianca Rossi",
    "Carla Diaz",
    "Chris Evans",
    "Cody Brooks",
    "Dana Scott",
    "Derek Hale",
    "Elena Petrova",
    "Ethan Wright",
    "Fatima Khan",
    "Felix Wagner",
    "Grace Kim",
    "Hana Sato",
    "Henry Ford",
    "Ivy Turner",
    "Jack Miller",
    "Jade Wilson",
    "Jonas Berg",
    "Kai Nakamura",
    "Laura Bell",
    "Leo Martin",
    "Lina Costa",
    "Maya Patel",
    "Mia Novak",
    "Noah Fischer",
    "Nora Lind",
    "Omar Aziz",
    "Paula Silva",
    "Quinn Avery",
    "Rosa Lopez",
    "Ryan Cole",
    "Sara Ahmed",
    "Sofia Greco",
    "Tom Baker",
    "Uma Rao",
    "Victor Hugo",
    "Will Jensen",
    "Yara Nasser",
    "Zoe Adams",
    "8 Ball Club",
)

val sectionIndexGroups: List<Pair<String, List<String>>> =
    sectionIndexNames
        .groupBy { name ->
            val first = name.first().uppercaseChar()
            if (first in 'A'..'Z') first.toString() else "#"
        }
        .toList()
        .sortedWith(compareBy({ it.first == "#" }, { it.first }))

@Composable
fun SectionIndexContacts(height: Dp) {
    val colors = EsuissTheme.colors
    var log by rememberSaveable { mutableStateOf("Tap or drag the letters.") }
    val shape = RoundedCornerShape(Radius.xl2)
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        SectionIndex(
            modifier = Modifier
                .fillMaxWidth()
                .height(height)
                .clip(shape)
                .border(1.dp, colors.border, shape),
            onValueChange = { letter -> log = "Jumped to $letter" },
        ) {
            sectionIndexGroups.forEach { (letter, group) ->
                SectionIndexSection(letter) {
                    items(group.size, key = { group[it] }) { index ->
                        SectionIndexRow(group[index], divided = index > 0) { log = "Opened ${group[index]}" }
                    }
                }
            }
        }
        Text(log, variant = TextVariant.Muted)
    }
}

@Composable
fun SectionIndexRow(name: String, divided: Boolean, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column(
        Modifier
            .fillMaxWidth()
            .padding(start = 16.dp, end = 40.dp)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick),
    ) {
        if (divided) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
        Text(name, modifier = Modifier.alpha(if (pressed) 0.6f else 1f).padding(vertical = 12.dp))
    }
}

API Reference

01

SectionIndex

The root that tracks section positions.

Also accepts the props of React.ComponentProps<typeof SectionIndexPrimitive.Root>.

02

SectionIndexScrollView

The scroll view that holds the sections.

Also accepts the props of React.ComponentProps<typeof SectionIndexPrimitive.ScrollView>.

03

SectionIndexSection

One section, registered by its value.

Also accepts the props of React.ComponentProps<typeof SectionIndexPrimitive.Section>.

04

SectionIndexHeader

A section heading; string children are styled.

Also accepts the props of React.ComponentProps<typeof View>.

05

SectionIndexBar

The letter bar with the drag bubble.

Also accepts the props of React.ComponentProps<typeof SectionIndexPrimitive.Bar>.

Accessibility

  • The bar is adjustable: swiping up or down with a screen reader moves to the previous or next section, and the current letter is announced as its value.
  • SectionIndexHeader has the heading role.

Notes

  • A selection haptic plays on each letter change from touch, if the app provides an adapter through HapticsProvider.
  • Letters use text-primary; the bubble uses bg-popover.
  • The bubble animation respects the system reduce motion setting.
NextSelection ListPreviousSearch Reveal

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • Compact
  4. 04API Reference
    • SectionIndex
    • SectionIndexSection
    • SectionIndexHeader
    • SectionIndexBar
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Compact
  10. 10API Reference
    • SectionIndex
    • SectionIndexScrollView
    • SectionIndexSection
    • SectionIndexHeader
    • SectionIndexBar
  11. 11Accessibility
  12. 12Notes