shadcn-svelte component

Fast Group Scroll

An accessible A–Z fast scroller for Svelte 5. Tap, drag, or use the keyboard to move through a long grouped list — with configurable bucketing, search and selection.

Heads up: this component was vibe coded. It is tested and has been checked in a real browser, but it has not seen production use — read the source and use it with care.

Install

Add it to a shadcn-svelte project straight from this site's registry:

npx shadcn-svelte@latest add https://prathje.github.io/fast-group-scroll-shadcn-svelte/r/fast-group-scroll.json

Or copy the three files in src/lib/fast-group-scroll/ into $lib/components/ui/fast-group-scroll/. There is nothing to install from npm, and no runtime dependency beyond Svelte 5 itself.

Basic usage

<script lang="ts">
  import FastGroupScroll from '$lib/components/ui/fast-group-scroll/fast-group-scroll.svelte';

  const contacts = [
    { id: 1, name: 'Ada Lovelace' },
    { id: 2, name: 'Grace Hopper' }
  ];
</script>

<FastGroupScroll
  items={contacts}
  getKey={(contact) => contact.id}
  getLabel={(contact) => contact.name}
>
  {#snippet item(contact)}
    <span>{contact.name}</span>
  {/snippet}
</FastGroupScroll>

Examples

Default

Accents fold into the base letter, digits and symbols collect under #.

A

AL Ada Lovelace
AT Alan Turing
ÁRR Ángela Ruiz Robles

B

BL Barbara Liskov

D

DK Donald Knuth

E

ED Edsger Dijkstra

F

FA Frances Allen

G

GH Grace Hopper

H

HL Hedy Lamarr

J

JG James Gosling

K

KT Ken Thompson

L

LT Linus Torvalds

M

MH Margaret Hamilton

O

ØO Øystein Ore

R

RP Radia Perlman

T

TB Tim Berners-Lee

Y

YM Yukihiro Matsumoto

#

3F 3Com Founders
Show the code
<FastGroupScroll {items} {getKey} {getLabel} height="22rem">
  {#snippet item(contact)}
    <div class="contact">
      <span class="avatar">AL</span>
      <span>{contact.name}</span>
    </div>
  {/snippet}
</FastGroupScroll>

Ringed selection

An outline instead of a filled marker, through CSS variables alone.

A

Ada Lovelace
Alan Turing
Ángela Ruiz Robles

B

Barbara Liskov

D

Donald Knuth

E

Edsger Dijkstra

F

Frances Allen

G

Grace Hopper

H

Hedy Lamarr

J

James Gosling

K

Ken Thompson

L

Linus Torvalds

M

Margaret Hamilton

O

Øystein Ore

R

Radia Perlman

T

Tim Berners-Lee

Y

Yukihiro Matsumoto

#

3Com Founders
Show the code
<div
  style="
    --fast-group-scroll-active-background: transparent;
    --fast-group-scroll-active-border: 1.5px solid hsl(258 90% 55%);
    --fast-group-scroll-active-color: hsl(258 90% 45%);
    --fast-group-scroll-active-size: 1.25rem;
  "
>
  <FastGroupScroll {items} {getKey} {getLabel}>…</FastGroupScroll>
</div>

Squared selection

Same variables, different shape and colour. The rail and the drag bubble follow.

A

Ada Lovelace
Alan Turing
Ángela Ruiz Robles

B

Barbara Liskov

D

Donald Knuth

E

Edsger Dijkstra

F

Frances Allen

G

Grace Hopper

H

Hedy Lamarr

J

James Gosling

K

Ken Thompson

L

Linus Torvalds

M

Margaret Hamilton

O

Øystein Ore

R

Radia Perlman

T

Tim Berners-Lee

Y

Yukihiro Matsumoto

#

3Com Founders
Show the code
<div
  style="
    --fast-group-scroll-active-background: hsl(258 90% 55%);
    --fast-group-scroll-active-radius: .3rem;
    --fast-group-scroll-rail-radius: .5rem;
    --fast-group-scroll-bubble-radius: .5rem;
  "
>
  <FastGroupScroll {items} {getKey} {getLabel}>…</FastGroupScroll>
</div>

No scrollbar

showScrollbar=false hides the native bar; the list still scrolls.

A

Ada Lovelace
Alan Turing
Ángela Ruiz Robles

B

Barbara Liskov

D

Donald Knuth

E

Edsger Dijkstra

F

Frances Allen

G

Grace Hopper

H

Hedy Lamarr

J

James Gosling

K

Ken Thompson

L

Linus Torvalds

M

Margaret Hamilton

O

Øystein Ore

R

Radia Perlman

T

Tim Berners-Lee

Y

Yukihiro Matsumoto

#

3Com Founders
Show the code
<FastGroupScroll {items} {getKey} {getLabel} showScrollbar={false}>
  {#snippet item(contact)}<span>{contact.name}</span>{/snippet}
</FastGroupScroll>

Custom letter

A letter snippet takes over rendering entirely. Honour labelled so it degrades the way the default does when the rail runs short.

A

Ada Lovelace
Alan Turing
Ángela Ruiz Robles

B

Barbara Liskov

D

Donald Knuth

E

Edsger Dijkstra

F

Frances Allen

G

Grace Hopper

H

Hedy Lamarr

J

James Gosling

K

Ken Thompson

L

Linus Torvalds

M

Margaret Hamilton

O

Øystein Ore

R

Radia Perlman

T

Tim Berners-Lee

Y

Yukihiro Matsumoto

#

3Com Founders
Show the code
<FastGroupScroll {items} {getKey} {getLabel}>
  {#snippet letter({ label, active, labelled })}
    <span class="pip" class:on={active}>{label}</span>
  {/snippet}
  {#snippet item(contact)}<span>{contact.name}</span>{/snippet}
</FastGroupScroll>

Grouped by team

getGroup buckets by any field; groupOrder fixes the order.

Research

Ada Lovelace
Grace Hopper

Platform

Ken Thompson
Radia Perlman

Design

Hedy Lamarr
Tim Berners-Lee
Show the code
<FastGroupScroll
  items={staff}
  {getKey}
  {getLabel}
  grouping={{
    getGroup: (person) => person.team,
    groupOrder: ['Research', 'Platform']
  }}
>
  {#snippet item(person)}<span>{person.name}</span>{/snippet}
</FastGroupScroll>

Custom heading

A heading snippet gets the label and that group's items.

A 3

Ada Lovelace
Alan Turing
Ángela Ruiz Robles

B 1

Barbara Liskov

D 1

Donald Knuth

E 1

Edsger Dijkstra

F 1

Frances Allen

G 1

Grace Hopper

H 1

Hedy Lamarr

J 1

James Gosling

K 1

Ken Thompson

L 1

Linus Torvalds

M 1

Margaret Hamilton

O 1

Øystein Ore

R 1

Radia Perlman

T 1

Tim Berners-Lee

Y 1

Yukihiro Matsumoto

# 1

3Com Founders
Show the code
<FastGroupScroll {items} {getKey} {getLabel}>
  {#snippet heading({ label, items })}
    <span class="head">
      <span>{label}</span>
      <span class="count">{items.length}</span>
    </span>
  {/snippet}
  {#snippet item(contact)}<span>{contact.name}</span>{/snippet}
</FastGroupScroll>

Custom bubble

The bubble snippet takes over the label that follows your finger down the rail. It is given the group under the pointer, and whether that group has anything to show — the component dims the bubble for you, and the flag is there if you want to say more. Filter the list first, then drag. headingLevel=4 puts the group headings under this card's own h3.

A

Ada Lovelace
Alan Turing
Ángela Ruiz Robles

B

Barbara Liskov

D

Donald Knuth

E

Edsger Dijkstra

F

Frances Allen

G

Grace Hopper

H

Hedy Lamarr

J

James Gosling

L

Linus Torvalds

M

Margaret Hamilton

R

Radia Perlman

Y

Yukihiro Matsumoto
Show the code
<FastGroupScroll {items} {getKey} {getLabel} headingLevel={4}>
  {#snippet bubble({ label })}
    <span class="bubble-text">{label}</span>
  {/snippet}
  {#snippet item(contact)}<span>{contact.name}</span>{/snippet}
</FastGroupScroll>

Custom rows

The item snippet renders anything. Here the separator is inset past the avatars, while the row background still spans the full width.

A

AL Ada Lovelace @adalovelace
AT Alan Turing @alanturing
ÁRR Ángela Ruiz Robles @ngelaruizrobles

B

BL Barbara Liskov @barbaraliskov

D

DK Donald Knuth @donaldknuth

E

ED Edsger Dijkstra @edsgerdijkstra

F

FA Frances Allen @francesallen

G

GH Grace Hopper @gracehopper

H

HL Hedy Lamarr @hedylamarr

J

JG James Gosling @jamesgosling

K

KT Ken Thompson @kenthompson

L

LT Linus Torvalds @linustorvalds

M

MH Margaret Hamilton @margarethamilton

O

ØO Øystein Ore @ysteinore

R

RP Radia Perlman @radiaperlman

T

TB Tim Berners-Lee @timbernerslee

Y

YM Yukihiro Matsumoto @yukihiromatsumoto

#

3F 3Com Founders @comfounders
Show the code
<div style="--fast-group-scroll-row-separator-inset: 3.6rem 0;">
  <FastGroupScroll {items} {getKey} {getLabel}>
    {#snippet item(contact)}
      <div class="row-card">
        <span class="avatar">AL</span>
        <span class="stack">
          <strong>{contact.name}</strong>
          <small>@handle</small>
        </span>
      </div>
    {/snippet}
  </FastGroupScroll>
</div>

Search

Your search box, the component's filter. Emptied groups stay in the rail and grey out instead of the alphabet jumping around. Search for something absent to see the empty snippet.

A

Ada Lovelace
Alan Turing
Ángela Ruiz Robles

B

Barbara Liskov

D

Donald Knuth

E

Edsger Dijkstra

F

Frances Allen

G

Grace Hopper

H

Hedy Lamarr

J

James Gosling

K

Ken Thompson

L

Linus Torvalds

M

Margaret Hamilton

O

Øystein Ore

R

Radia Perlman

T

Tim Berners-Lee

Y

Yukihiro Matsumoto

#

3Com Founders
Show the code
<script>
  let query = $state('');
  const matches = (contact) =>
    contact.name.toLowerCase().includes(query.trim().toLowerCase());
</script>

<input type="search" bind:value={query} />

<FastGroupScroll {items} {getKey} {getLabel} filter={matches}>
  {#snippet item(contact)}<span>{contact.name}</span>{/snippet}
  {#snippet empty()}No one matches “{query}”.{/snippet}
</FastGroupScroll>

Single select

One at a time; clicking the chosen row clears it. Selected 1.

A

Ada Lovelace
Alan Turing
Ángela Ruiz Robles

B

Barbara Liskov

D

Donald Knuth

E

Edsger Dijkstra

F

Frances Allen

G

Grace Hopper

H

Hedy Lamarr

J

James Gosling

K

Ken Thompson

L

Linus Torvalds

M

Margaret Hamilton

O

Øystein Ore

R

Radia Perlman

T

Tim Berners-Lee

Y

Yukihiro Matsumoto

#

3Com Founders
Show the code
<script>
  let chosen = $state([]);
</script>

<FastGroupScroll
  {items}
  {getKey}
  {getLabel}
  selectionMode="single"
  bind:selected={chosen}
>
  {#snippet item(contact, { selected })}
    <span class:on={selected}>{contact.name}</span>
  {/snippet}
</FastGroupScroll>

Multi select with disabled rows

0 selected. Two entries are disabled, which greys their letters too.

A

Ada Lovelace
Alan Turing (unavailable)
Ángela Ruiz Robles

B

Barbara Liskov

D

Donald Knuth

E

Edsger Dijkstra

F

Frances Allen (unavailable)

G

Grace Hopper

H

Hedy Lamarr

J

James Gosling

K

Ken Thompson

L

Linus Torvalds

M

Margaret Hamilton

O

Øystein Ore

R

Radia Perlman

T

Tim Berners-Lee

Y

Yukihiro Matsumoto

#

3Com Founders
Show the code
<FastGroupScroll
  {items}
  {getKey}
  {getLabel}
  selectionMode="multiple"
  disabled={(contact) => contact.id === 1}
  bind:selected={picked}
  onSelectionChange={(keys, items) => console.log(keys, items)}
>
  {#snippet item(contact, { selected, disabled })}
    <span>{contact.name}{disabled ? ' (unavailable)' : ''}</span>
  {/snippet}
</FastGroupScroll>

Row clicks

onItemClick alone makes rows activatable with no selection. Last: nothing yet.

A

Ada Lovelace
Alan Turing
Ángela Ruiz Robles

B

Barbara Liskov

D

Donald Knuth

E

Edsger Dijkstra

F

Frances Allen

G

Grace Hopper

H

Hedy Lamarr

J

James Gosling

K

Ken Thompson

L

Linus Torvalds

M

Margaret Hamilton

O

Øystein Ore

R

Radia Perlman

T

Tim Berners-Lee

Y

Yukihiro Matsumoto

#

3Com Founders
Show the code
<FastGroupScroll
  {items}
  {getKey}
  {getLabel}
  onItemClick={(contact, event) => open(contact)}
>
  {#snippet item(contact)}<span>{contact.name}</span>{/snippet}
</FastGroupScroll>

52 groups

Too many to print, so every row stays draggable and clickable while the labels thin out to dots. The first, last and active labels always print, and nothing is ever clipped.

0

0ample entry

1

1ample entry

2

2ample entry

3

3ample entry

4

4ample entry

5

5ample entry

6

6ample entry

7

7ample entry

8

8ample entry

9

9ample entry

A

Aample entry

B

Bample entry

C

Cample entry

D

Dample entry

E

Eample entry

F

Fample entry

G

Gample entry

H

Hample entry

I

Iample entry

J

Jample entry

K

Kample entry

L

Lample entry

M

Mample entry

N

Nample entry

O

Oample entry

P

Pample entry

Q

Qample entry

R

Rample entry

S

Sample entry

T

Tample entry

U

Uample entry

V

Vample entry

W

Wample entry

X

Xample entry

Y

Yample entry

Z

Zample entry

Α

Αample entry

Β

Βample entry

Γ

Γample entry

Δ

Δample entry

Ε

Εample entry

Ζ

Ζample entry

Η

Ηample entry

Θ

Θample entry

А

Аample entry

Б

Бample entry

В

Вample entry

Г

Гample entry

Д

Дample entry

Е

Еample entry

Ж

Жample entry

З

Зample entry
Show the code
<FastGroupScroll
  items={manyGroups}
  {getKey}
  {getLabel}
  grouping={{ groupDigits: false }}
>
  {#snippet item(entry)}<span>{entry.name}</span>{/snippet}
</FastGroupScroll>

Right to left

dir="rtl" puts the rail on the left and the drag bubble beside it, measured rather than mirrored. The generated strings are translatable too.

ا

أحمد منصور

ب

بدر الدين

ت

تامر حسني

ث

ثريا الحاج

ج

جميلة بوحيرد

ح

حسن فتحي

خ

خديجة بنت خويلد

د

دينا الشربيني

ر

رشيد ياسين

ز

زها حديد

س

سميرة توفيق

ع

عمر الشريف

ف

فيروز الرحباني

ل

ليلى مراد

ن

نجيب محفوظ
Show the code
<!-- dir is not a prop; it falls through to the root like any other attribute. -->
<FastGroupScroll
  dir="rtl"
  items={contacts}
  {getKey}
  {getLabel}
  locale="ar"
  indexLabel="فهرس المجموعات"
  jumpLabel={(label) => `انتقل إلى ${label}`}
  jumpAnnouncement={(label) => `مجموعة ${label}`}
>
  {#snippet item(contact)}<span>{contact.name}</span>{/snippet}
</FastGroupScroll>

Props

PropTypeDefaultDescription
itemsT[]requiredItems to sort and group.
getKey(item: T) => string | numberrequiredStable keyed-each identity.
getLabel(item: T) => stringrequiredText used for sorting and grouping.
itemSnippet<[T, ItemContext]>requiredRow renderer, given { selected, disabled, index }.
headingSnippet<[HeadingContext<T>]>Group heading renderer, given { label, items, index }.
letterSnippet<[LetterContext]>Replaces the default index marker.
emptySnippetShown in place of the list when the filter matches nothing.
bubbleSnippet<[BubbleContext]>Replaces the label that follows a drag, given { label, disabled }.
headingLevel2 | 3 | 4 | 5 | 62Heading element for each group.
showBubblebooleantrueShow the label that follows a drag down the rail.
localestring | string[]browserPassed to Intl.Collator and case conversion.
groupingGroupOptions<T>{}Bucketing and group order.
heightstring32remComponent height.
showScrollbarbooleantrueHides the native bar without disabling scrolling.
filter(item: T) => booleanHides non-matching items; the rail keeps its groups.
disabled(item: T) => booleanRenders an item greyed and unselectable.
selectionMode'none' | 'single' | 'multiple'noneTurns the list into a listbox.
selectedFastGroupScrollItemKey[][]Bindable selected keys, an array in both modes.
onSelectionChange(keys, items) => voidFired after a selection change.
onItemClick(item, event) => voidMakes rows activatable; click, Enter and Space.
onActiveChange(label: string) => voidCalled when the active group changes.
scrollBehaviorScrollBehaviorautoDowngraded to auto under prefers-reduced-motion.
indexLabelstringGroup indexAccessible name for the index rail.
listLabelstringGrouped listAccessible name for the list, when it is a listbox.
jumpLabel(label) => stringJump to {label}Accessible name for one rail button.
jumpAnnouncement(label) => stringGroup {label}What the live region says after a jump.
classstringemptyAdditional root class. Other attributes land on the root too.

Grouping options

Passed as grouping. By default an item lands in the group of its first letter, uppercased for the locale, with combining marks folded away — so ábel sits under A. Anything with no usable leading letter falls into #, which always sorts last.

OptionDefaultDescription
foldDiacriticstrueWhen false, ábel gets its own Á group.
groupDigitstrueWhen false, digits get one group each.
otherLabel#Label of the catch-all group, always sorted last.
groupMapRemaps computed labels, as a record or a function.
getGroupReplaces the first-letter rule outright.
groupOrderThese groups lead; the rest follow in locale order.

CSS variables

Each one is read as var(--name, fallback) at its point of use, so you can set it on the component or on any ancestor. Defaults written as --muted refer to the shadcn-svelte theme variable of that name.

VariableDefaultDescription
--fast-group-scroll-rail-width1.75remMinimum rail width; grows to fit longer labels.
--fast-group-scroll-rail-touch-width2.5remMinimum rail width where the pointer is coarse.
--fast-group-scroll-rail-max-width40%Width at which labels start to ellipsize.
--fast-group-scroll-rail-gap.25remGap either side of the rail.
--fast-group-scroll-rail-radius9999pxShape of the rail's drag tint.
--fast-group-scroll-drag-background--mutedRail tint while dragging.
--fast-group-scroll-letter-color--muted-foregroundInactive letters.
--fast-group-scroll-letter-hover-color--foregroundHovered letter.
--fast-group-scroll-active-background--primaryFill of the active marker.
--fast-group-scroll-active-color--primary-foregroundActive letter's text.
--fast-group-scroll-active-border0 solid transparentBorder shorthand for the marker.
--fast-group-scroll-active-size1.15remMinimum marker size; grows around longer labels.
--fast-group-scroll-active-radius9999pxMarker shape.
--fast-group-scroll-bubble-size3.5remMinimum size of the drag bubble.
--fast-group-scroll-bubble-radius1remDrag bubble shape.
--fast-group-scroll-bubble-background--primaryDrag bubble fill.
--fast-group-scroll-bubble-color--primary-foregroundDrag bubble text.
--fast-group-scroll-bubble-font-size1.5remDrag bubble text size.
--fast-group-scroll-heading-background--mutedSticky group heading fill.
--fast-group-scroll-heading-positionstickySet to static for headings that scroll away.
--fast-group-scroll-heading-backdrop-filterblur(8px)Set to none for a flat heading bar.
--fast-group-scroll-heading-colorinheritedGroup heading text.
--fast-group-scroll-heading-padding.5rem 1remGroup heading padding.
--fast-group-scroll-heading-font-size.75remGroup heading text size.
--fast-group-scroll-row-padding-block.75remRow height.
--fast-group-scroll-row-padding-inline1remRow inset.
--fast-group-scroll-row-font-size.875remRow text size.
--fast-group-scroll-row-border-color--borderRow separator colour.
--fast-group-scroll-row-separator-inset0Separator inset, passed to inset-inline.
--fast-group-scroll-row-hover-background--mutedHovered selectable row.
--fast-group-scroll-row-selected-background--accentSelected row.
--fast-group-scroll-disabled-opacity.45Disabled rows and greyed letters.

Known limitations

  • Every row renders — there is no windowing. Fine for the few thousand rows a contact list reaches, but a much larger list will want virtualisation.
  • The rail maps the whole alphabet onto its own height, so a very tall list on a very short rail moves in coarse jumps — that is the trade every fast scroller makes.
  • Vibe coded, and not yet used in production.