Fast Group Scroll — grouped-list fast scrolling for Svelte 5

5 min read Original article ↗

shadcn-svelte component

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

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.

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.

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.