# 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.

Source: https://ui.suiss.app/docs/interactions/section-index

**React**

**Example**

```tsx
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

```bash title="Terminal"
npx shadcn@latest add @esuiss/section-index
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add interactions section-index
```

### Manual

1. Add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper).

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

```tsx title="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
    )
      return node
  }
  return document.scrollingElement as HTMLElement
}

function displayItems(letters: string[], capacity: number) {
  if (capacity >= letters.length)
    return letters.map((letter) => ({ key: letter, label: letter }))
  const slots = Math.max(3, capacity % 2 === 0 ? capacity - 1 : capacity)
  const letterAt = (slot: number) =>
    letters[Math.round((slot / (slots - 1)) * (letters.length - 1))]
  return Array.from({ length: slots }, (_, slot) =>
    slot % 2 === 0
      ? { key: letterAt(slot), label: letterAt(slot) }
      : { key: `after-${letterAt(slot - 1)}`, label: "•" }
  )
}

function SectionIndex({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="section-index"
      className={cn("relative", className)}
      {...props}
    />
  )
}

function SectionIndexSection({
  value,
  className,
  ...props
}: React.ComponentProps<"section"> & { value: string }) {
  return (
    <section
      data-slot="section-index-section"
      data-value={value}
      className={className}
      {...props}
    />
  )
}

function SectionIndexHeader({
  className,
  ...props
}: React.ComponentProps<"h3">) {
  return (
    <h3
      data-slot="section-index-header"
      className={cn(
        "sticky top-0 bg-background/85 px-4 py-1.5 text-sm font-semibold text-muted-foreground backdrop-blur-xl",
        className
      )}
      {...props}
    />
  )
}

function SectionIndexBar({
  letters = LETTERS,
  onValueChange,
  className,
  ref: forwardedRef,
  ...props
}: Omit<React.ComponentProps<"div">, "children"> & {
  letters?: string[]
  onValueChange?: (value: string) => void
}) {
  const ref = React.useRef<HTMLDivElement>(null)
  const [current, setCurrent] = React.useState(0)
  const [drag, setDrag] = React.useState<{ index: number; y: number } | null>(
    null
  )
  const [capacity, setCapacity] = React.useState(letters.length)

  React.useEffect(() => {
    const root = ref.current?.closest<HTMLElement>("[data-slot=section-index]")
    if (!root) return
    const observer = new ResizeObserver(() =>
      setCapacity(Math.floor((root.clientHeight - ROW_HEIGHT) / ROW_HEIGHT))
    )
    observer.observe(root)
    return () => observer.disconnect()
  }, [])

  const jump = (index: number) => {
    setCurrent(index)
    const root = ref.current?.closest("[data-slot=section-index]")
    const sections = Array.from(
      root?.querySelectorAll<HTMLElement>(
        "[data-slot=section-index-section]"
      ) ?? []
    )
    const target =
      sections.find(
        (section) => letters.indexOf(section.dataset.value ?? "") >= index
      ) ?? sections.at(-1)
    if (!target) return
    const parent = scrollParent(target)
    const offset =
      parent === document.scrollingElement
        ? 0
        : parent.getBoundingClientRect().top + parent.clientTop
    parent.scrollTop += target.getBoundingClientRect().top - offset
    const letter = letters[index]
    if (letter !== undefined) onValueChange?.(letter)
  }

  const indexAt = (clientY: number) => {
    const rect = ref.current?.getBoundingClientRect()
    if (!rect) return 0
    const fraction = Math.min(
      Math.max((clientY - rect.top) / rect.height, 0),
      0.9999
    )
    return Math.floor(fraction * letters.length)
  }

  const track = (clientY: number) => {
    const rect = ref.current?.getBoundingClientRect()
    if (!rect) return
    const index = indexAt(clientY)
    const y = Math.min(Math.max(clientY - rect.top, 0), rect.height)
    setDrag({ index, y })
    if (index !== current || drag === null) jump(index)
  }

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const last = letters.length - 1
    const steps: Record<string, number> = {
      ArrowDown: current + 1,
      ArrowRight: current + 1,
      ArrowUp: current - 1,
      ArrowLeft: current - 1,
      PageDown: current + PAGE,
      PageUp: current - PAGE,
      Home: 0,
      End: last,
    }
    let next = steps[event.key]
    if (next === undefined && event.key.length === 1) {
      const typed = letters.indexOf(event.key.toUpperCase())
      if (typed !== -1) next = typed
    }
    if (next === undefined) return
    event.preventDefault()
    jump(Math.min(Math.max(next, 0), last))
  }

  return (
    <div
      ref={(node) => {
        ref.current = node
        if (typeof forwardedRef === "function") return forwardedRef(node)
        if (forwardedRef) forwardedRef.current = node
      }}
      role="slider"
      tabIndex={0}
      data-slot="section-index-bar"
      data-dragging={drag ? "" : undefined}
      aria-label="Section index"
      aria-orientation="vertical"
      aria-valuemin={0}
      aria-valuemax={letters.length - 1}
      aria-valuenow={current}
      aria-valuetext={letters[current]}
      onPointerDown={(event) => {
        if (event.button !== 0) return
        event.preventDefault()
        event.currentTarget.setPointerCapture(event.pointerId)
        track(event.clientY)
      }}
      onPointerMove={(event) => {
        if (drag) track(event.clientY)
      }}
      onPointerUp={() => setDrag(null)}
      onPointerCancel={() => setDrag(null)}
      onKeyDown={onKeyDown}
      className={cn(
        "absolute end-3 top-1/2 z-20 flex max-h-[calc(100%-1rem)] -translate-y-1/2 cursor-pointer touch-none flex-col items-center rounded-full px-1.5 text-xs leading-4 font-semibold text-primary outline-none select-none before:absolute before:inset-y-0 before:-inset-s-3 before:inset-e-0 before:content-[''] focus-visible:ring-3 focus-visible:ring-ring/50",
        className
      )}
      {...props}
    >
      {displayItems(letters, capacity).map((item) => (
        <span
          key={item.key}
          aria-hidden
          className="flex h-4 min-w-3 items-center justify-center"
        >
          {item.label}
        </span>
      ))}
      {drag && (
        <span
          aria-hidden
          data-slot="section-index-bubble"
          style={{ top: drag.y }}
          className="pointer-events-none absolute end-full me-4 flex size-14 -translate-y-1/2 items-center justify-center rounded-full bg-popover text-3xl font-semibold text-popover-foreground shadow-xl ring-1 ring-border transition-[scale,opacity] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] starting:scale-50 starting:opacity-0"
        >
          {letters[drag.index]}
        </span>
      )}
    </div>
  )
}

export {
  SectionIndex,
  SectionIndexBar,
  SectionIndexHeader,
  SectionIndexSection,
}
```

3. Update the import paths to match your project.

## Usage

```tsx
import { SectionIndex, SectionIndexSection, SectionIndexHeader, SectionIndexBar } from "@/components/interactions/section-index"
```

```tsx
<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.

**Example**

```tsx
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

### SectionIndex

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

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

### SectionIndexSection

A section of the list identified by a letter.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | Required | The letter this section belongs to. |

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

### SectionIndexHeader

The sticky title at the top of a section.

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

### SectionIndexBar

The vertical letter bar that jumps between sections.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `letters` | `string[]` | `LETTERS` | The 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.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/interactions-section-index--default.light.webp)

```tsx
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]" />
  )
}
```

```swift
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)
  }
}
```

```kotlin
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

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/section-index
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add section-index
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add section-index --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add section-index --platform kotlin
```

The CLI also adds what it builds on: [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: [Text](https://ui.suiss.app/docs/components/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.

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

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

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

```tsx title="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))}
      {...props}
    />
  )
}

function SectionIndexScrollView({
  className,
  ...props
}: React.ComponentProps<typeof SectionIndexPrimitive.ScrollView>) {
  return (
    <SectionIndexPrimitive.ScrollView
      className={cn("flex-1", className)}
      {...props}
    />
  )
}

function SectionIndexSection(
  props: React.ComponentProps<typeof SectionIndexPrimitive.Section>
) {
  return <SectionIndexPrimitive.Section {...props} />
}

function SectionIndexHeader({
  className,
  children,
  ...props
}: React.ComponentProps<typeof View>) {
  return (
    <View
      role="heading"
      className={cn("bg-background px-4 py-1.5", className)}
      {...props}
    >
      {typeof children === "string" || typeof children === "number" ? (
        <Text className="text-sm font-semibold text-muted-foreground">
          {children}
        </Text>
      ) : (
        children
      )}
    </View>
  )
}

function SectionIndexBar({
  className,
  ...props
}: Omit<React.ComponentProps<typeof SectionIndexPrimitive.Bar>, "children">) {
  return (
    <View
      className="absolute top-0 bottom-0 end-3 justify-center"
      style={{ pointerEvents: "box-none" }}
    >
      <SectionIndexPrimitive.Bar
        className={(state) =>
          cn(
            "items-center rounded-full px-1.5",
            resolveClassName(className, state)
          )
        }
        {...props}
      >
        {(state) => (
          <>
            {state.items.map((item) => (
              <View
                key={item.key}
                className="h-4 min-w-3 items-center justify-center"
                style={{ pointerEvents: "none" }}
              >
                <Text className="text-xs font-semibold text-primary">
                  {item.label}
                </Text>
              </View>
            ))}
            {state.dragging ? (
              <AnimatedView
                entering={ZoomIn.duration(200).reduceMotion(
                  ReduceMotion.System
                )}
                exiting={FadeOut.duration(150).reduceMotion(
                  ReduceMotion.System
                )}
                className="absolute end-full me-4 size-14 items-center justify-center rounded-full border border-border bg-popover shadow-xl"
                style={{ top: state.dragY - 28, pointerEvents: "none" }}
              >
                <Text className="text-3xl font-semibold text-foreground">
                  {state.letter}
                </Text>
              </AnimatedView>
            ) : null}
          </>
        )}
      </SectionIndexPrimitive.Bar>
    </View>
  )
}

export {
  SectionIndex,
  SectionIndexBar,
  SectionIndexHeader,
  SectionIndexScrollView,
  SectionIndexSection,
}
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: [Text](https://ui.suiss.app/docs/components/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.

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

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

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

```tsx title="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))}
      {...props}
    />
  )
}

function SectionIndexScrollView({
  className,
  ...props
}: React.ComponentProps<typeof SectionIndexPrimitive.ScrollView>) {
  return (
    <SectionIndexPrimitive.ScrollView
      className={cn("flex-1", className)}
      {...props}
    />
  )
}

function SectionIndexSection(
  props: React.ComponentProps<typeof SectionIndexPrimitive.Section>
) {
  return <SectionIndexPrimitive.Section {...props} />
}

function SectionIndexHeader({
  className,
  children,
  ...props
}: React.ComponentProps<typeof View>) {
  return (
    <View
      role="heading"
      className={cn("bg-background px-4 py-1.5", className)}
      {...props}
    >
      {typeof children === "string" || typeof children === "number" ? (
        <Text className="text-sm font-semibold text-muted-foreground">
          {children}
        </Text>
      ) : (
        children
      )}
    </View>
  )
}

function SectionIndexBar({
  className,
  ...props
}: Omit<React.ComponentProps<typeof SectionIndexPrimitive.Bar>, "children">) {
  return (
    <View
      className="absolute top-0 bottom-0 end-3 justify-center"
      style={{ pointerEvents: "box-none" }}
    >
      <SectionIndexPrimitive.Bar
        className={(state) =>
          cn(
            "items-center rounded-full px-1.5",
            resolveClassName(className, state)
          )
        }
        {...props}
      >
        {(state) => (
          <>
            {state.items.map((item) => (
              <View
                key={item.key}
                className="h-4 min-w-3 items-center justify-center"
                style={{ pointerEvents: "none" }}
              >
                <Text className="text-xs font-semibold text-primary">
                  {item.label}
                </Text>
              </View>
            ))}
            {state.dragging ? (
              <AnimatedView
                entering={ZoomIn.duration(200).reduceMotion(
                  ReduceMotion.System
                )}
                exiting={FadeOut.duration(150).reduceMotion(
                  ReduceMotion.System
                )}
                className="absolute end-full me-4 size-14 items-center justify-center rounded-full border border-border bg-popover shadow-xl"
                style={{ top: state.dragY - 28, pointerEvents: "none" }}
              >
                <Text className="text-3xl font-semibold text-foreground">
                  {state.letter}
                </Text>
              </AnimatedView>
            ) : null}
          </>
        )}
      </SectionIndexPrimitive.Bar>
    </View>
  )
}

export {
  SectionIndex,
  SectionIndexBar,
  SectionIndexHeader,
  SectionIndexScrollView,
  SectionIndexSection,
}
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: [Text](https://ui.suiss.app/docs/components/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.

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

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

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

```tsx title="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",
    top: 0,
    bottom: 0,
    end: 12,
    justifyContent: "center",
    pointerEvents: "box-none",
  },
  bar: {
    alignItems: "center",
    borderRadius: radius.full,
    paddingHorizontal: 6,
  },
  letter: {
    height: 16,
    minWidth: 12,
    alignItems: "center",
    justifyContent: "center",
    pointerEvents: "none",
  },
  letterText: { ...fontSize.xs, fontWeight: "600" },
  bubble: {
    position: "absolute",
    end: "100%",
    marginEnd: 16,
    width: 56,
    height: 56,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
    borderWidth: 1,
    pointerEvents: "none",
    ...shadow.xl,
  },
  bubbleText: { ...fontSize["3xl"], fontWeight: "600" },
})

function SectionIndex({
  style,
  ...props
}: React.ComponentProps<typeof SectionIndexPrimitive.Root>) {
  return (
    <SectionIndexPrimitive.Root
      style={(state) => [styles.root, resolveStyle(style, state) as ViewStyle]}
      {...props}
    />
  )
}

function SectionIndexScrollView({
  style,
  ...props
}: React.ComponentProps<typeof SectionIndexPrimitive.ScrollView>) {
  return (
    <SectionIndexPrimitive.ScrollView
      style={[styles.scroll, style]}
      {...props}
    />
  )
}

function SectionIndexSection(
  props: React.ComponentProps<typeof SectionIndexPrimitive.Section>
) {
  return <SectionIndexPrimitive.Section {...props} />
}

function SectionIndexHeader({
  style,
  children,
  ...props
}: React.ComponentProps<typeof View>) {
  const { colors } = useTheme()
  return (
    <View
      role="heading"
      style={[styles.header, { backgroundColor: colors.background }, style]}
      {...props}
    >
      {typeof children === "string" || typeof children === "number" ? (
        <Text style={[styles.headerText, { color: colors.mutedForeground }]}>
          {children}
        </Text>
      ) : (
        children
      )}
    </View>
  )
}

function SectionIndexBar({
  style,
  ...props
}: Omit<React.ComponentProps<typeof SectionIndexPrimitive.Bar>, "children">) {
  const { colors } = useTheme()
  return (
    <View style={styles.barFrame}>
      <SectionIndexPrimitive.Bar
        style={(state) => [styles.bar, resolveStyle(style, state) as ViewStyle]}
        {...props}
      >
        {(state) => (
          <>
            {state.items.map((item) => (
              <View key={item.key} style={styles.letter}>
                <Text style={[styles.letterText, { color: colors.primary }]}>
                  {item.label}
                </Text>
              </View>
            ))}
            {state.dragging ? (
              <AnimatedView
                entering={ZoomIn.duration(200).reduceMotion(
                  ReduceMotion.System
                )}
                exiting={FadeOut.duration(150).reduceMotion(
                  ReduceMotion.System
                )}
                style={[
                  styles.bubble,
                  {
                    top: state.dragY - 28,
                    backgroundColor: colors.popover,
                    borderColor: colors.border,
                  },
                ]}
              >
                <Text style={[styles.bubbleText, { color: colors.foreground }]}>
                  {state.letter}
                </Text>
              </AnimatedView>
            ) : null}
          </>
        )}
      </SectionIndexPrimitive.Bar>
    </View>
  )
}

export {
  SectionIndex,
  SectionIndexBar,
  SectionIndexHeader,
  SectionIndexScrollView,
  SectionIndexSection,
}
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="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] {
    guard capacity < letters.count, letters.count > 1 else {
      return letters.map { SectionIndexItem(key: $0, label: $0) }
    }
    let slots = max(3, capacity % 2 == 0 ? capacity - 1 : capacity)
    func letter(at slot: Int) -> String {
      letters[Int((Double(slot) / Double(slots - 1) * Double(letters.count - 1)).rounded())]
    }
    return (0..<slots).map { slot in
      slot % 2 == 0
        ? SectionIndexItem(key: letter(at: slot), label: letter(at: slot))
        : SectionIndexItem(key: "after-\(letter(at: slot - 1))", label: "•")
    }
  }

  static func index(at y: CGFloat, height: CGFloat, count: Int) -> Int {
    guard count > 0, height > 0 else { return 0 }
    let fraction = min(max(y / height, 0), 0.9999)
    return Int((fraction * CGFloat(count)).rounded(.down))
  }

  static func merge(_ known: [String], _ seen: [String]) -> [String] {
    var result = known
    for value in seen where !result.contains(value) { result.append(value) }
    return result.enumerated().sorted { lhs, rhs in
      let left = letters.firstIndex(of: lhs.element) ?? letters.count
      let right = letters.firstIndex(of: rhs.element) ?? letters.count
      return left == right ? lhs.offset < rhs.offset : left < right
    }.map(\.element)
  }

  static func target(sections: [String], letters: [String], index: Int) -> String? {
    sections.first { value in
      guard let position = letters.firstIndex(of: value) else { return false }
      return position >= index
    } ?? sections.last
  }
}

struct SectionIndexContext: @unchecked Sendable {
  var sections: [String] = []
  var jump: @MainActor @Sendable (String) -> Void = { _ in }
}

extension EnvironmentValues {
  @Entry var sectionIndex = SectionIndexContext()
}

struct SectionIndexSectionsKey: PreferenceKey {
  static var defaultValue: [String] { [] }

  static func reduce(value: inout [String], nextValue: () -> [String]) {
    value.append(contentsOf: nextValue())
  }
}

public struct SectionIndex<Content: View>: View {
  let known: [String]?
  let content: Content

  @State private var sections: [String] = []

  public init(sections: [String]? = nil, @ViewBuilder content: () -> Content) {
    self.known = sections
    self.content = content()
  }

  public var body: some View {
    ScrollViewReader { proxy in
      let context = SectionIndexContext(sections: known ?? sections) { value in
        proxy.scrollTo(value, anchor: .top)
      }
      ZStack(alignment: .trailing) {
        content
      }
      .environment(\.sectionIndex, context)
    }
    .onPreferenceChange(SectionIndexSectionsKey.self) { value in sections = SectionIndexMath.merge(sections, value) }
  }
}

public struct SectionIndexSection<Content: View, Header: View>: View {
  let value: String
  let content: Content
  let header: Header

  public init(value: String, @ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header) {
    self.value = value
    self.content = content()
    self.header = header()
  }

  public var body: some View {
    Section {
      content
    } header: {
      header
        .id(value)
        .preference(key: SectionIndexSectionsKey.self, value: [value])
    }
  }
}

extension SectionIndexSection where Header == SectionIndexHeader {
  public init(_ value: String, @ViewBuilder content: () -> Content) {
    self.init(value: value, content: content) { SectionIndexHeader(verbatim: value) }
  }
}

public struct SectionIndexHeader: View {
  let title: Text

  @Environment(\.screenBackground) private var background

  public init(_ title: LocalizedStringKey) {
    self.title = Text(title)
  }

  public init(verbatim title: String) {
    self.title = Text(verbatim: title)
  }

  public var body: some View {
    title
      .scaledFont(.sm, weight: .semibold)
      .foregroundStyle(EsuissColor.mutedForeground)
      .padding(.horizontal, 16)
      .padding(.vertical, 6)
      .frame(maxWidth: .infinity, alignment: .leading)
      .background(background.opacity(0.95))
      .accessibilityAddTraits(.isHeader)
  }
}

public struct SectionIndexBar: View {
  let letters: [String]
  let onValueChange: ((String) -> Void)?

  @State private var current = 0
  @State private var drag: SectionIndexDrag?
  @State private var height: CGFloat = 0
  @State private var available: CGFloat = 0
  @Environment(\.sectionIndex) private var sectionIndex

  public init(letters: [String]? = nil, onValueChange: ((String) -> Void)? = nil) {
    self.letters = letters ?? SectionIndexMath.letters
    self.onValueChange = onValueChange
  }

  init(letters: [String]? = nil, dragging index: Int, at y: CGFloat) {
    self.letters = letters ?? SectionIndexMath.letters
    self.onValueChange = nil
    _current = State(initialValue: index)
    _drag = State(initialValue: SectionIndexDrag(index: index, y: y))
  }

  public var body: some View {
    let capacity = available > 0 ? SectionIndexMath.capacity(height: available) : letters.count
    let items = SectionIndexMath.displayItems(letters, capacity: capacity)
    VStack(spacing: 0) {
      ForEach(items, id: \.key) { item in
        Text(verbatim: item.label)
          .scaledFont(TextStyle.xs, weight: .semibold)
          .foregroundStyle(EsuissColor.primary)
          .frame(minWidth: 12, minHeight: SectionIndexMath.rowHeight, maxHeight: SectionIndexMath.rowHeight)
      }
    }
    .padding(.horizontal, 6)
    .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height = $0 }
    .padding(.leading, 12)
    .contentShape(Rectangle())
    .gesture(
      DragGesture(minimumDistance: 0, coordinateSpace: .local)
        .onChanged { value in track(value.location.y) }
        .onEnded { _ in drag = nil }
    )
    .overlay(alignment: .topLeading) {
      if let drag, letters.indices.contains(drag.index) {
        SectionIndexBubble(letter: letters[drag.index])
          .offset(x: -72, y: drag.y - 28)
          .transition(.scale(scale: 0.5).combined(with: .opacity))
      }
    }
    .animation(.timingCurve(0.32, 0.72, 0, 1, duration: 0.2), value: drag == nil)
    .hapticFeedback(.selection, trigger: drag?.index) { old, new in HapticMoments.lifted(old, new) }
    .frame(maxHeight: .infinity)
    .padding(.vertical, 8)
    .padding(.trailing, 6)
    .onGeometryChange(for: CGFloat.self) { $0.size.height - 16 } action: { available = $0 }
    .accessibilityElement(children: .ignore)
    .accessibilityLabel(Text("Section index"))
    .accessibilityValue(Text(verbatim: letters.indices.contains(current) ? letters[current] : ""))
    .accessibilityAdjustableAction { direction in
      switch direction {
      case .increment: jump(min(current + 1, letters.count - 1))
      case .decrement: jump(max(current - 1, 0))
      @unknown default: break
      }
    }
  }

  private func track(_ y: CGFloat) {
    let index = SectionIndexMath.index(at: y, height: height, count: letters.count)
    let clamped = min(max(y, 0), height)
    let first = drag == nil
    drag = SectionIndexDrag(index: index, y: clamped)
    if index != current || first { jump(index) }
  }

  private func jump(_ index: Int) {
    current = index
    guard let target = SectionIndexMath.target(sections: sectionIndex.sections, letters: letters, index: index) else { return }
    sectionIndex.jump(target)
    if letters.indices.contains(index) { onValueChange?(letters[index]) }
  }
}

struct SectionIndexDrag: Equatable {
  let index: Int
  let y: CGFloat
}

private struct SectionIndexBubble: View {
  let letter: String

  var body: some View {
    Text(verbatim: letter)
      .scaledFont(TextStyle.xl2, weight: .semibold)
      .foregroundStyle(EsuissColor.foreground)
      .frame(width: 56, height: 56)
      .background(Circle().fill(EsuissColor.popover).shadow(.lg))
      .overlay(Circle().strokeBorder(EsuissColor.border))
      .allowsHitTesting(false)
      .accessibilityHidden(true)
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Header.swift`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="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
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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.setProgress
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.rememberHaptics
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow
import kotlin.math.floor
import kotlin.math.roundToInt
import kotlinx.coroutines.launch

val SectionIndexLetters: List<String> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".map { it.toString() } + "#"

private val RowHeight = 16.dp

interface SectionIndexSectionScope {
    fun item(key: Any? = null, content: @Composable LazyItemScope.() -> Unit)

    fun items(count: Int, key: ((Int) -> Any)? = null, content: @Composable LazyItemScope.(index: Int) -> Unit)
}

interface SectionIndexScope {
    fun SectionIndexSection(value: String, header: String? = value, content: SectionIndexSectionScope.() -> Unit)
}

private class SectionEntry(val count: Int, val key: ((Int) -> Any)?, val content: @Composable LazyItemScope.(Int) -> Unit)

private class SectionSpec(val value: String, val header: String?, val entries: List<SectionEntry>) {
    val size: Int get() = (if (header != null) 1 else 0) + entries.sumOf { it.count }
}

private class SectionIndexSectionScopeImpl : SectionIndexSectionScope {
    val entries = mutableListOf<SectionEntry>()

    override fun item(key: Any?, content: @Composable LazyItemScope.() -> Unit) {
        entries += SectionEntry(1, key?.let { k -> { k } }) { content() }
    }

    override fun items(count: Int, key: ((Int) -> Any)?, content: @Composable LazyItemScope.(index: Int) -> Unit) {
        entries += SectionEntry(count, key, content)
    }
}

private class SectionIndexScopeImpl : SectionIndexScope {
    val sections = mutableListOf<SectionSpec>()

    override fun SectionIndexSection(value: String, header: String?, content: SectionIndexSectionScope.() -> Unit) {
        sections += SectionSpec(value, header, SectionIndexSectionScopeImpl().apply(content).entries.toList())
    }
}

@Composable
fun SectionIndex(
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
    letters: List<String> = SectionIndexLetters,
    onValueChange: ((String) -> Unit)? = null,
    contentPadding: PaddingValues = PaddingValues(0.dp),
    content: SectionIndexScope.() -> Unit,
) {
    val sections = SectionIndexScopeImpl().apply(content).sections.toList()
    val starts = sections.runningFold(0) { total, section -> total + section.size }
    val scope = rememberCoroutineScope()
    Box(modifier = modifier) {
        LazyColumn(state = state, contentPadding = contentPadding) {
            sections.forEach { section ->
                if (section.header != null) {
                    stickyHeader(key = "section-index-${section.value}", contentType = "section-index-header") {
                        SectionIndexHeader(section.header)
                    }
                }
                section.entries.forEach { entry ->
                    items(entry.count, key = entry.key) { index -> entry.content(this, index) }
                }
            }
        }
        if (sections.isNotEmpty()) {
            SectionIndexBar(
                letters = letters,
                onValueChange = { letter ->
                    val position = letters.indexOf(letter)
                    val target = sections.indexOfFirst { letters.indexOf(it.value) >= position }.takeIf { it >= 0 } ?: sections.lastIndex
                    scope.launch { state.scrollToItem(starts[target]) }
                    onValueChange?.invoke(letter)
                },
                modifier = Modifier.align(Alignment.CenterEnd).padding(end = 12.dp),
            )
        }
    }
}

@Composable
fun SectionIndexHeader(text: String, modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    BasicText(
        text,
        modifier = modifier
            .fillMaxWidth()
            .background(colors.background.copy(alpha = 0.94f))
            .padding(horizontal = 16.dp, vertical = 6.dp)
            .semantics { heading() },
        style = Typography.sm.copy(color = colors.mutedForeground, fontWeight = FontWeight.SemiBold),
    )
}

private fun displayItems(letters: List<String>, capacity: Int): List<String> {
    if (capacity >= letters.size) return letters
    val slots = maxOf(3, if (capacity % 2 == 0) capacity - 1 else capacity)
    fun letterAt(slot: Int) = letters[((slot.toFloat() / (slots - 1)) * (letters.size - 1)).roundToInt()]
    return List(slots) { slot -> if (slot % 2 == 0) letterAt(slot) else "•" }
}

@Composable
fun SectionIndexBar(
    modifier: Modifier = Modifier,
    letters: List<String> = SectionIndexLetters,
    onValueChange: ((String) -> Unit)? = null,
    accessibilityLabel: String = "Section index",
) {
    if (letters.isEmpty()) return
    val colors = EsuissTheme.colors
    val haptics = rememberHaptics()
    val density = LocalDensity.current
    var current by remember { mutableIntStateOf(0) }
    var dragging by remember { mutableStateOf(false) }
    var dragY by remember { mutableFloatStateOf(0f) }
    var columnHeight by remember { mutableIntStateOf(0) }
    val change by rememberUpdatedState(onValueChange)
    val currentLetters by rememberUpdatedState(letters)

    fun jump(index: Int) {
        if (index != current) haptics.selection()
        current = index
        currentLetters.getOrNull(index)?.let { change?.invoke(it) }
    }

    BoxWithConstraints(modifier = modifier) {
        val available = with(density) { constraints.maxHeight.toDp() } - 16.dp
        val capacity = if (constraints.hasBoundedHeight) floor(((available - RowHeight) / RowHeight)).toInt() else letters.size
        val items = displayItems(letters, capacity)
        Box(
            modifier = Modifier
                .pointerInput(Unit) {
                    fun track(y: Float, first: Boolean) {
                        val height = columnHeight.toFloat().coerceAtLeast(1f)
                        val clamped = y.coerceIn(0f, height)
                        dragY = clamped
                        val fraction = (clamped / height).coerceIn(0f, 0.9999f)
                        val index = (fraction * currentLetters.size).toInt()
                        if (index != current || first) jump(index)
                    }
                    awaitEachGesture {
                        val down = awaitFirstDown()
                        down.consume()
                        dragging = true
                        track(down.position.y, true)
                        while (true) {
                            val event = awaitPointerEvent()
                            val pointer = event.changes.firstOrNull { it.id == down.id } ?: break
                            if (!pointer.pressed) break
                            pointer.consume()
                            track(pointer.position.y, false)
                        }
                        dragging = false
                    }
                }
                .semantics {
                    contentDescription = accessibilityLabel
                    stateDescription = letters.getOrElse(current) { "" }
                    progressBarRangeInfo = ProgressBarRangeInfo(current.toFloat(), 0f..letters.lastIndex.toFloat(), letters.lastIndex - 1)
                    setProgress { value ->
                        jump(value.roundToInt().coerceIn(0, letters.lastIndex))
                        true
                    }
                }
                .padding(start = 12.dp),
        ) {
            Column(
                modifier = Modifier
                    .onSizeChanged { columnHeight = it.height }
                    .padding(horizontal = 6.dp),
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                items.forEach { label ->
                    Box(modifier = Modifier.height(RowHeight).widthIn(min = 12.dp), contentAlignment = Alignment.Center) {
                        BasicText(
                            label,
                            style = Typography.xs.copy(color = colors.primary, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
                        )
                    }
                }
            }
            AnimatedVisibility(
                visible = dragging,
                modifier = Modifier.layout { measurable, _ ->
                    val placeable = measurable.measure(Constraints())
                    layout(0, 0) {
                        placeable.place(-(56.dp + 16.dp).roundToPx(), (dragY - 28.dp.toPx()).roundToInt())
                    }
                },
                enter = fadeIn(tween(200)) + scaleIn(tween(200), initialScale = 0.5f),
                exit = fadeOut(tween(200)) + scaleOut(tween(200), targetScale = 0.5f),
            ) {
                Box(
                    modifier = Modifier
                        .size(56.dp)
                        .shadow(Shadows.lg, CircleShape)
                        .background(colors.popover, CircleShape)
                        .border(1.dp, colors.border, CircleShape),
                    contentAlignment = Alignment.Center,
                ) {
                    BasicText(
                        letters.getOrElse(current) { "" },
                        style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
                    )
                }
            }
        }
    }
}
```

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { SectionIndex, SectionIndexScrollView, SectionIndexSection, SectionIndexHeader, SectionIndexBar } from "@/components/interactions/section-index"
```

```tsx
<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.

![Preview](https://ui.suiss.app/previews/interactions-section-index--compact.light.webp)

```tsx
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" />
  )
}
```

```swift
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)
  }
}
```

```kotlin
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

### SectionIndex

The root that tracks section positions.

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

### SectionIndexScrollView

The scroll view that holds the sections.

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

### SectionIndexSection

One section, registered by its `value`.

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

### SectionIndexHeader

A section heading; string children are styled.

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

### 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.

