Index Scrubber
The A-Z rail down the side of a long list — letters that magnify under the thumb, and a rail that condenses rather than overflowing.
Walkthrough
1. Basic
Drag the rail. The letters swell under the finger, the callout names the one you are on, and the list jumps to it as you go — groupByInitial shaped the data and the ids, and the scrubber found the scroller on its own.
A
B
C
D
E
F
G
H
I
J
K
L
M
N
O
P
Q
R
S
T
U
V
W
Y
Z
<script lang="ts"> import { Avatar, IndexScrubber, List, ListItem, Text, groupByInitial } from 'omaris'; const NAMES = [ 'Aland Barzani', 'Amina Tofiq', 'Bahar Rasul', 'Chnar Salim', 'Dilan Rashid', 'Evan Hoshyar', 'Farah Jamil', 'Goran Azad', 'Hawkar Sabir', 'Ibrahim Latif', 'Jwan Sirwan', 'Karwan Aziz', 'Lana Abdullah', 'Mariam Salih', 'Nasrin Hiwa', 'Omar Ali', 'Peshraw Diyar', 'Qasim Talib', 'Rezan Shahen', 'Sara Mahmoud', 'Tara Zana', 'Umed Kamaran', 'Viyan Soran', 'Wria Halkawt', 'Yusuf Karim', 'Zainab Hassan' ]; const groups = groupByInitial(NAMES, (name) => name, { fill: true }); let active = $state<string | undefined>('A');</script><div class="relative w-full max-w-sm overflow-hidden rounded-shape-lg border border-border"> <!-- svelte-ignore a11y_no_noninteractive_tabindex --> <div class="h-96 overflow-y-auto pe-5" tabindex="0" role="group" aria-label="Contacts"> {#each groups.filter((group) => group.items.length) as group (group.key)} <Text as="h3" variant="label-md" id={group.id} class="sticky top-0 z-1 bg-surface-container/95 px-4 py-1.5 text-primary backdrop-blur-sm" > {group.label} </Text> <List variant="plain"> {#each group.items as name (name)} <ListItem headline={name} density="compact"> {#snippet leading()}<Avatar {name} size="sm" />{/snippet} </ListItem> {/each} </List> {/each} </div> <IndexScrubber sections={groups} bind:active class="absolute inset-y-1 end-0.5" /></div> 2. Condensed
Nowhere near the room for 27 letters. The rail drops to every second or third and puts a dot in the gap, the way a phone does — and the drag still addresses every letter, so nothing is lost by not being drawn.
A
B
C
D
E
F
G
H
I
J
K
L
M
N
O
P
Q
R
S
T
U
V
W
X
Y
Z
<script lang="ts"> import { IndexScrubber, List, ListItem, Text, groupByInitial, ALPHABET } from 'omaris'; const WORDS = ALPHABET.flatMap((letter) => [`${letter}sha`, `${letter}oran`]); const groups = groupByInitial(WORDS, (word) => word, { fill: true });</script><div class="relative w-full max-w-sm overflow-hidden rounded-shape-lg border border-border"> <!-- svelte-ignore a11y_no_noninteractive_tabindex --> <div class="h-44 overflow-y-auto pe-5" tabindex="0" role="group" aria-label="Contacts"> {#each groups as group (group.key)} <Text as="h3" variant="label-md" id={group.id} class="px-4 py-1 text-primary"> {group.label} </Text> <List variant="plain"> {#each group.items as word (word)} <ListItem headline={word} density="compact" /> {/each} </List> {/each} </div> <IndexScrubber sections={groups} class="absolute inset-y-1 end-0.5" /></div> 3. Your own callout
The callout is a snippet, so it can carry more than a letter — a count, a label, a preview of the first row. bubbleShape picks any of the 36 outlines for the default one; this one is replaced outright.
A
B
C
D
E
H
K
M
N
R
S
Z
<script lang="ts"> import { IndexScrubber, List, ListItem, Text, groupByInitial } from 'omaris'; const CITIES = [ 'Akre', 'Amedi', 'Baghdad', 'Basra', 'Chamchamal', 'Duhok', 'Erbil', 'Halabja', 'Karbala', 'Kirkuk', 'Mosul', 'Najaf', 'Ranya', 'Soran', 'Sulaymaniyah', 'Zakho' ]; const groups = groupByInitial(CITIES, (city) => city);</script><div class="relative w-full max-w-sm overflow-hidden rounded-shape-lg border border-border"> <!-- svelte-ignore a11y_no_noninteractive_tabindex --> <div class="h-72 overflow-y-auto pe-5" tabindex="0" role="group" aria-label="Contacts"> {#each groups as group (group.key)} <Text as="h3" variant="label-md" id={group.id} class="px-4 py-1.5 text-primary"> {group.label} </Text> <List variant="plain"> {#each group.items as city (city)} <ListItem headline={city} density="compact" /> {/each} </List> {/each} </div> <IndexScrubber sections={groups} class="absolute inset-y-1 end-0.5"> {#snippet callout(section)} <div class="flex items-baseline gap-2 rounded-shape-md bg-inverse-surface px-4 py-2 text-inverse-surface-foreground shadow-4" > <span class="text-title-lg font-semibold">{section.label}</span> <span class="text-label-sm opacity-70">{section.count} cities</span> </div> {/snippet} </IndexScrubber></div> 4. Overridden
The overridden case: the rail on the leading edge, the magnification turned off for a plain one, and every part restyled through classes.
A
B
C
D
E
F
G
H
I
K
L
M
<script lang="ts"> import { IndexScrubber, List, ListItem, Text, groupByInitial } from 'omaris'; const TAGS = [ 'analytics', 'billing', 'couriers', 'delivery', 'exports', 'fraud', 'gateway', 'hours', 'invoices', 'kitchen', 'loyalty', 'menu' ]; const groups = groupByInitial(TAGS, (tag) => tag);</script><div class="relative w-full max-w-sm overflow-hidden rounded-shape-lg bg-surface-container-low ps-9"> <!-- svelte-ignore a11y_no_noninteractive_tabindex --> <div class="h-64 overflow-y-auto" tabindex="0" role="group" aria-label="Contacts"> {#each groups as group (group.key)} <Text as="h3" variant="label-md" id={group.id} class="px-4 py-1.5">{group.label}</Text> <List variant="plain"> {#each group.items as tag (tag)} <ListItem headline={tag} density="compact" /> {/each} </List> {/each} </div> <IndexScrubber side="start" magnify={0} bulge={0} bubbleShape="pill" bubbleSize={64} sections={groups} classes={{ rail: 'bg-inverse-surface/90 px-1.5', row: 'text-inverse-surface-foreground/70', dot: 'bg-inverse-surface-foreground/50' }} class="absolute inset-y-1 start-0.5" /></div>