Tree at scale (virtual + lazy)

SvTree scaling to 10,100 nodes via fixed-row virtualization (only ~15 rows in the DOM), plus a lazy-loading tree that fetches each folder’s children on first expand with a spinner. Keyboard, selection and cascading checkboxes intact.

A live, editable Svelte 5 component example from the SvGrid gallery (Layout). Read the SvGrid UI component docs for the full API.

What this example shows

SvTree at scale: a virtualized 10,000-node tree (only visible rows in the DOM) and a lazy-loading tree that fetches each folder's children on first expand. Both keep keyboard nav, selection and cascading checkboxes.

Imports, features and API used

Imports: @svgrid/grid

Source code (324-tree-scale.svelte)

<script lang="ts">
  /**
   * SvTree at scale: a virtualized 10,000-node tree (only visible rows in the
   * DOM) and a lazy-loading tree that fetches each folder's children on first
   * expand. Both keep keyboard nav, selection and cascading checkboxes.
   */
  import { SvTree } from '@svgrid/grid'
  import type { SvTreeNode } from '@svgrid/grid'

  // ---- 10,000 nodes: 100 folders x 100 files -------------------------------
  const bigNodes: SvTreeNode[] = Array.from({ length: 100 }, (_, f) => ({
    id: `f${f}`,
    label: `Folder ${f + 1}`,
    children: Array.from({ length: 100 }, (_, i) => ({ id: `f${f}-${i}`, label: `File ${f + 1}.${i + 1}` })),
  }))
  const total = 100 + 100 * 100
  let selected = $state<string | null>('f0-0')
  let expanded = $state<string[]>(['f0'])

  // ---- Lazy loading: folders fetch children on first expand ----------------
  const lazyNodes: SvTreeNode[] = [
    { id: 'docs', label: 'Documents', lazy: true },
    { id: 'media', label: 'Media', lazy: true },
    { id: 'projects', label: 'Projects', lazy: true },
  ]
  let lazyExpanded = $state<string[]>([])
  let lazySelected = $state<string | null>(null)

  // Simulate a server round-trip (deterministic - no Math.random).
  function loadChildren(node: SvTreeNode): Promise<SvTreeNode[]> {
    return new Promise((resolve) =>
      setTimeout(() => {
        resolve(
          Array.from({ length: 5 }, (_, i) => ({
            id: `${node.id}-${i}`,
            label: `${node.label} item ${i + 1}`,
            // Nest one more lazy level to show it recurses.
            lazy: i === 0,
          })),
        )
      }, 650),
    )
  }
</script>

<div class="wrap">
  <header>
    <h2>Tree at scale</h2>
    <p>Two production patterns: a <strong>virtualized {total.toLocaleString()}-node</strong> tree, and a <strong>lazy-loading</strong> tree that fetches children on demand.</p>
  </header>

  <div class="cols">
    <section>
      <h3>Virtualized ({total.toLocaleString()} nodes)</h3>
      <SvTree
        nodes={bigNodes}
        {selected}
        expandedIds={expanded}
        virtual
        height={360}
        rowHeight={30}
        onSelect={(id) => (selected = id)}
        onToggle={(id, open) => (expanded = open ? [...expanded, id] : expanded.filter((e) => e !== id))}
      />
      <p class="note">Expand a few folders and scroll - only ~15 rows are ever in the DOM.</p>
    </section>

    <section>
      <h3>Lazy-loaded (fetch on expand)</h3>
      <SvTree
        nodes={lazyNodes}
        selected={lazySelected}
        expandedIds={lazyExpanded}
        {loadChildren}
        height={360}
        onSelect={(id) => (lazySelected = id)}
        onToggle={(id, open) => (lazyExpanded = open ? [...lazyExpanded, id] : lazyExpanded.filter((e) => e !== id))}
      />
      <p class="note">Each folder spins while it loads (~650ms), then reveals its children. The first child is itself lazy.</p>
    </section>
  </div>
</div>

<style>
  .wrap { padding: 20px; max-width: 720px; display: flex; flex-direction: column; gap: 18px; }
  header h2 { margin: 0 0 4px; font-size: 20px; font-weight: 700; }
  header p { margin: 0; color: var(--sg-muted, #64748b); font-size: 13.5px; line-height: 1.5; }
  .cols { display: flex; gap: 28px; flex-wrap: wrap; align-items: start; }
  section h3 { margin: 0 0 10px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
  .note { margin: 8px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); max-width: 300px; line-height: 1.5; }
</style>

View this example on GitHub

Related documentation

Related articles

More Layout examples

  • Account & security settings console - A real SaaS settings surface composed from the UI kit: SvMenubar app bar, promise + Undo-action toasts on save, SvPopconfirm on destructive rows, SvHoverCard teammate previews, and frame input adornments (leading icons, prefix affixes, masked API key). Profile / Team / Security tabs over SvCard + SvStat.
  • Invoice builder - An adornment-heavy money form: currency prefixes, % suffixes, masked tax IDs (frame adornments), line items with SvPopconfirm delete + Undo action toasts, live SvStat totals, an SvHoverCard tax hint, and a promise toast on Send. Pure UI-kit composition.
  • Operations KPI dashboard - A KPI console: SvStat + SvSparkline tiles, an SvGauge SLA dial, SvHoverCard drill-down previews per service, an SvMenubar toolbar (View / Range / Actions) and a promise toast on Refresh. Pure UI-kit composition, no grid dependency.
  • Form: rich field types - SvForm reaching the whole input suite from one schema - phone, country, mask, combobox, radio, slider, tags, datetime and file - plus per-field help text, a readonly field, column span, and a promise toast on submit.
  • Form: cascading fields - SvForm dependent selects - a child list derives from the parent value via a function `options`, and `dependsOn` clears the child when the parent changes so a stale selection never lingers. Country -> State -> City.