Skip to content
omaris

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'
Learn

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

Aland Barzani Aland Barzani
Amina Tofiq Amina Tofiq

B

Bahar Rasul Bahar Rasul

C

Chnar Salim Chnar Salim

D

Dilan Rashid Dilan Rashid

E

Evan Hoshyar Evan Hoshyar

F

Farah Jamil Farah Jamil

G

Goran Azad Goran Azad

H

Hawkar Sabir Hawkar Sabir

I

Ibrahim Latif Ibrahim Latif

J

Jwan Sirwan Jwan Sirwan

K

Karwan Aziz Karwan Aziz

L

Lana Abdullah Lana Abdullah

M

Mariam Salih Mariam Salih

N

Nasrin Hiwa Nasrin Hiwa

O

Omar Ali Omar Ali

P

Peshraw Diyar Peshraw Diyar

Q

Qasim Talib Qasim Talib

R

Rezan Shahen Rezan Shahen

S

Sara Mahmoud Sara Mahmoud

T

Tara Zana Tara Zana

U

Umed Kamaran Umed Kamaran

V

Viyan Soran Viyan Soran

W

Wria Halkawt Wria Halkawt

Y

Yusuf Karim Yusuf Karim

Z

Zainab Hassan Zainab Hassan
<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

Asha
Aoran

B

Bsha
Boran

C

Csha
Coran

D

Dsha
Doran

E

Esha
Eoran

F

Fsha
Foran

G

Gsha
Goran

H

Hsha
Horan

I

Isha
Ioran

J

Jsha
Joran

K

Ksha
Koran

L

Lsha
Loran

M

Msha
Moran

N

Nsha
Noran

O

Osha
Ooran

P

Psha
Poran

Q

Qsha
Qoran

R

Rsha
Roran

S

Ssha
Soran

T

Tsha
Toran

U

Usha
Uoran

V

Vsha
Voran

W

Wsha
Woran

X

Xsha
Xoran

Y

Ysha
Yoran

Z

Zsha
Zoran
<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

Akre
Amedi

B

Baghdad
Basra

C

Chamchamal

D

Duhok

E

Erbil

H

Halabja

K

Karbala
Kirkuk

M

Mosul

N

Najaf

R

Ranya

S

Soran
Sulaymaniyah

Z

Zakho
<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

analytics

B

billing

C

couriers

D

delivery

E

exports

F

fraud

G

gateway

H

hours

I

invoices

K

kitchen

L

loyalty

M

menu
<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 groupByInitial and render the headings with the ids 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 a relative container.
  • Set offset to 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 observe on 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-6 so nothing sits under the rail.
  • Expect thirty-odd sections all to be drawn. It condenses on purpose; every letter stays reachable by dragging.
  • Wire onselect to your own scrolling and leave scroll on. 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 bindable
string

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.