Navigation
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.
import { IndexScrubber } from 'omaris' Examples
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> 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> 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> 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> When to use it
Use it for
- A long alphabetical list on a touch screen: contacts, cities, products, tags, members. The rail turns a thousand rows into one thumb movement.
- Any list already split into sections with short headings: a letter, a date, a country code, a rating.
- A list people aim at rather than scroll through. A drag aims; a tap is a destination. The component treats them differently.
- A directory in a panel or a sheet as well as a full page. It finds the scroller it is inside instead of assuming the window.
- A steady rail across refreshes.
groupByInitial(…, { fill: true })keeps every letter, greyed where empty, so the letters never move under the thumb.
Not for
- A list that is not sorted, or sorted by relevance or a manual order → nothing, or Pagination.
- Finding one known item → Search Bar or Combobox. Typing three letters beats aiming.
- Fewer than about fifty rows → plain scrolling, or Tabs if there really are sections.
- Moving between sections of one page → Table of Contents. That rail is for reading position; this one is for a data set.
- A desktop-only screen → a sticky header per section, or Table with a sort.
Do
- Build the sections with
groupByInitialand render the headings with theids it produced. The scrubber finds them by id. - Give the rail height with room either side of the list:
class="absolute inset-y-1 end-0.5"over arelativecontainer. - Set
offsetto the height of a sticky header, or every jump lands with the first row hidden behind it. - Keep labels to one character; three turn the rail into a column of tiny words. Use
magnify={0}for a quiet rail; drag, callout and condensing still work. - Leave
observeon so the rail follows an ordinary scroll too and the active letter is always right.
Don't
- Put it over content it hides. Give the list
pe-6so nothing sits under the rail. - Expect thirty-odd sections all to be drawn. It condenses on purpose; every letter stays reachable by dragging.
- Wire
onselectto your own scrolling and leavescrollon. Pick one, or the list scrolls twice. - Use it with section ids that are not on the page. It reports and does not scroll, which looks broken.
- Turn haptics on globally just for this. The shared store is off by default and this component follows it.
Quick reference
side end(default)start
API
IndexScrubber
The A-Z rail down the side of a long list — the one every phone's contacts app has, and almost nothing on the web does.
Drag it and the list follows your thumb immediately; let go and it stops where you left it. Tap a letter and it scrolls there smoothly instead — a drag is aiming, a tap is a destination, and they should not feel the same.
The letters magnify under the finger. Each one is scaled and pushed toward the content by a Gaussian falloff around the pointer, so the rail swells into a lens rather than highlighting one row: the letter you are on is legible at a glance without a callout, and the ones either side of it tell you which way to move. The callout is there anyway, as a morphing MD3 shape, because a thumb covers the rail it is dragging.
It condenses instead of overflowing. A rail that cannot fit every letter in the height it has drops to every second or third and puts a dot between them, exactly as iOS does — and the drag still addresses all of them, so a letter that isn't drawn is still reachable.
Given sections whose ids are on the page it finds the scroller itself, scrolls to them, and tracks which one you are in as you scroll normally. Give it onselect instead and it will only report.
The keyboard gets a real path through it: one tab stop, then the arrows walk the letters and Home/End jump to the ends.
import { IndexScrubber } from 'omaris' const groups = groupByInitial(contacts, (c) => c.name, { fill: true });<div class="relative"> <List>…sections with `id={group.id}`…</List> <IndexScrubber sections={groups} class="absolute inset-y-0 end-1" /></div> Parts
Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.
root- The frame. Position it —
absolute inset-y-0 end-1, usually. rail- The rail itself. It grows a surface under it while it is being dragged, which is what stops the letters from sitting on top of the list they are scrolling.
track- The letters' box. Every row is an equal share of its height.
row- One letter.
dot- A row standing in for the letters this rail had no room for.
bubble- The callout beside the finger.
Props
sections required IndexSection[] The stops, in the order the list has them. groupByInitial builds these.
active bindablestring The section currently in view. Bindable.
onselect (key: string, section: IndexSection) => void Called on every change, dragging included.
container Defaults to null
HTMLElement | string | null The scroller. A selector, an element, or left out — in which case the nearest scrollable ancestor of the target section is used, falling back to the page.
offset Defaults to 0
number Distance from the top of the scroller that counts as "at" a section, in px.
scroll Defaults to true
boolean Scroll to the section. Off leaves the scrolling entirely to onselect.
observe Defaults to true
boolean Follow the scroller and keep active current as the user scrolls normally.
side Defaults to 'end'
'end' | 'start' Which edge the rail is pinned to. It only decides which way the letters bulge.
end- The usual: the rail on the trailing edge, letters bulging inward.
start
magnify Defaults to 0.85
number The magnification under the finger. 0 turns it off and leaves a plain rail.
spread Defaults to 46
number How far the magnification reaches, in px. Wider is softer.
bulge Defaults to 14
number How far the swollen letters lean toward the content, in px.
bubble Defaults to true
boolean The callout beside the finger.
bubbleShape Defaults to 'cookie-9'
ShapeName Its outline. Any of the 36 in the shape library.
bubbleSize Defaults to 60
number Its size, in px.
haptics Defaults to true
boolean A tick as each new letter comes under the finger. Follows the shared store.
rowHeight Defaults to 13
number Height one letter needs, in px — what decides when the rail condenses.
label Defaults to 'Section index'
string Accessible name for the rail.
callout Snippet<[IndexSection]> Replace the callout. It is handed the section under the finger.
class string classes IndexScrubberClasses Per-part Tailwind overrides. class still covers the root.