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.
View on GitHub Install Examples Props
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 #.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | required | Items to sort and group. |
getKey | (item: T) => string | number | required | Stable keyed-each identity. |
getLabel | (item: T) => string | required | Text used for sorting and grouping. |
item | Snippet<[T, ItemContext]> | required | Row renderer, given { selected, disabled, index }. |
heading | Snippet<[HeadingContext<T>]> | — | Group heading renderer, given { label, items, index }. |
letter | Snippet<[LetterContext]> | — | Replaces the default index marker. |
empty | Snippet | — | Shown in place of the list when the filter matches nothing. |
bubble | Snippet<[BubbleContext]> | — | Replaces the label that follows a drag, given { label, disabled }. |
headingLevel | 2 | 3 | 4 | 5 | 6 | 2 | Heading element for each group. |
showBubble | boolean | true | Show the label that follows a drag down the rail. |
locale | string | string[] | browser | Passed to Intl.Collator and case conversion. |
grouping | GroupOptions<T> | {} | Bucketing and group order. |
height | string | 32rem | Component height. |
showScrollbar | boolean | true | Hides the native bar without disabling scrolling. |
filter | (item: T) => boolean | — | Hides non-matching items; the rail keeps its groups. |
disabled | (item: T) => boolean | — | Renders an item greyed and unselectable. |
selectionMode | 'none' | 'single' | 'multiple' | none | Turns the list into a listbox. |
selected | FastGroupScrollItemKey[] | [] | Bindable selected keys, an array in both modes. |
onSelectionChange | (keys, items) => void | — | Fired after a selection change. |
onItemClick | (item, event) => void | — | Makes rows activatable; click, Enter and Space. |
onActiveChange | (label: string) => void | — | Called when the active group changes. |
scrollBehavior | ScrollBehavior | auto | Downgraded to auto under prefers-reduced-motion. |
indexLabel | string | Group index | Accessible name for the index rail. |
listLabel | string | Grouped list | Accessible name for the list, when it is a listbox. |
jumpLabel | (label) => string | Jump to {label} | Accessible name for one rail button. |
jumpAnnouncement | (label) => string | Group {label} | What the live region says after a jump. |
class | string | empty | Additional 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.
| Option | Default | Description |
|---|---|---|
foldDiacritics | true | When false, ábel gets its own Á group. |
groupDigits | true | When false, digits get one group each. |
otherLabel | # | Label of the catch-all group, always sorted last. |
groupMap | — | Remaps computed labels, as a record or a function. |
getGroup | — | Replaces the first-letter rule outright. |
groupOrder | — | These 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.
| Variable | Default | Description |
|---|---|---|
--fast-group-scroll-rail-width | 1.75rem | Minimum rail width; grows to fit longer labels. |
--fast-group-scroll-rail-touch-width | 2.5rem | Minimum rail width where the pointer is coarse. |
--fast-group-scroll-rail-max-width | 40% | Width at which labels start to ellipsize. |
--fast-group-scroll-rail-gap | .25rem | Gap either side of the rail. |
--fast-group-scroll-rail-radius | 9999px | Shape of the rail's drag tint. |
--fast-group-scroll-drag-background | --muted | Rail tint while dragging. |
--fast-group-scroll-letter-color | --muted-foreground | Inactive letters. |
--fast-group-scroll-letter-hover-color | --foreground | Hovered letter. |
--fast-group-scroll-active-background | --primary | Fill of the active marker. |
--fast-group-scroll-active-color | --primary-foreground | Active letter's text. |
--fast-group-scroll-active-border | 0 solid transparent | Border shorthand for the marker. |
--fast-group-scroll-active-size | 1.15rem | Minimum marker size; grows around longer labels. |
--fast-group-scroll-active-radius | 9999px | Marker shape. |
--fast-group-scroll-bubble-size | 3.5rem | Minimum size of the drag bubble. |
--fast-group-scroll-bubble-radius | 1rem | Drag bubble shape. |
--fast-group-scroll-bubble-background | --primary | Drag bubble fill. |
--fast-group-scroll-bubble-color | --primary-foreground | Drag bubble text. |
--fast-group-scroll-bubble-font-size | 1.5rem | Drag bubble text size. |
--fast-group-scroll-heading-background | --muted | Sticky group heading fill. |
--fast-group-scroll-heading-position | sticky | Set to static for headings that scroll away. |
--fast-group-scroll-heading-backdrop-filter | blur(8px) | Set to none for a flat heading bar. |
--fast-group-scroll-heading-color | inherited | Group heading text. |
--fast-group-scroll-heading-padding | .5rem 1rem | Group heading padding. |
--fast-group-scroll-heading-font-size | .75rem | Group heading text size. |
--fast-group-scroll-row-padding-block | .75rem | Row height. |
--fast-group-scroll-row-padding-inline | 1rem | Row inset. |
--fast-group-scroll-row-font-size | .875rem | Row text size. |
--fast-group-scroll-row-border-color | --border | Row separator colour. |
--fast-group-scroll-row-separator-inset | 0 | Separator inset, passed to inset-inline. |
--fast-group-scroll-row-hover-background | --muted | Hovered selectable row. |
--fast-group-scroll-row-selected-background | --accent | Selected row. |
--fast-group-scroll-disabled-opacity | .45 | Disabled 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.