/ Examples
groupsselection

Contact List

A–Z grouped contacts with sticky section headers using withGroups. Headers stick to the top and get pushed out by the next group — just like iOS Contacts.

Source
// Contact List — A–Z grouped contacts with sticky section headers
// Demonstrates withGroups plugin with sticky/inline toggle
// and withSelection for click-to-select with detail panel

import { vlist, withGroups, withSelection } from "vlist";
import { makeContacts } from "../../src/data/people.js";
import { createStats } from "../stats.js";
import { initLetterGrid } from "./controls.js";

// =============================================================================
// Constants
// =============================================================================

export const TOTAL_CONTACTS = 1000;
export const ITEM_HEIGHT = 64;
export const HEADER_HEIGHT = 36;

// =============================================================================
// Data — sorted by last name
// =============================================================================

export const contacts = makeContacts(TOTAL_CONTACTS).sort((a, b) =>
  a.lastName.localeCompare(b.lastName),
);

// Build group index: letter → first contact index
export const groupIndex = new Map();
for (let i = 0; i < contacts.length; i++) {
  const letter = contacts[i].lastName[0].toUpperCase();
  if (!groupIndex.has(letter)) {
    groupIndex.set(letter, i);
  }
}

export const sortedGroups = [...groupIndex.entries()].sort((a, b) =>
  a[0].localeCompare(b[0]),
);

// =============================================================================
// State — exported so controls.js can read/write
// =============================================================================

export let currentHeaderMode = "sticky"; // "sticky" | "inline" | "off"
export let list = null;

export function setCurrentHeaderMode(v) {
  currentHeaderMode = v;
}

// =============================================================================
// Templates
// =============================================================================

const renderContact = (item) => `
  <div class="contact">
    <div class="contact__avatar" style="background:${item.color}">${item.initials}</div>
    <div class="contact__info">
      <div class="contact__name">${item.firstName} ${item.lastName}</div>
      <div class="contact__detail">${item.department} · ${item.email}</div>
    </div>
  </div>
`;

const renderGroupHeader = (group) => `
  <div class="group-header">
    <span class="group-header__letter">${group}</span>
    <span class="group-header__line"></span>
  </div>
`;

// =============================================================================
// Stats — shared footer (progress, velocity, visible/total)
// =============================================================================

export const stats = createStats({
  getList: () => list,
  getTotal: () => contacts.length,
  getItemHeight: () => ITEM_HEIGHT,
  container: "#list-container",
});

// =============================================================================
// Create / Recreate list
// =============================================================================

let firstVisibleIndex = 0;

export function createList() {
  if (list) {
    list.destroy();
    list = null;
  }

  const container = document.getElementById("list-container");
  container.innerHTML = "";

  const builder = vlist({
    container: "#list-container",
    ariaLabel: "Contact list",
    item: {
      height: ITEM_HEIGHT,
      template: renderContact,
    },
    items: contacts,
  });

  if (currentHeaderMode !== "off") {
    builder.use(
      withGroups({
        getGroupForIndex: (index) => contacts[index].lastName[0].toUpperCase(),
        headerHeight: HEADER_HEIGHT,
        headerTemplate: (group) => renderGroupHeader(group),
        sticky: currentHeaderMode === "sticky",
      }),
    );
  }

  builder.use(withSelection({ mode: "single" }));

  list = builder.build();

  list.on("scroll", stats.scheduleUpdate);
  list.on("range:change", ({ range }) => {
    firstVisibleIndex = range.start;
    stats.scheduleUpdate();
  });
  list.on("velocity:change", ({ velocity }) => stats.onVelocity(velocity));

  list.on("selection:change", ({ selected, items }) => {
    if (items.length > 0) {
      showContactDetail(items[0]);
    } else {
      clearContactDetail();
    }
  });

  // Restore scroll position
  if (firstVisibleIndex > 0) {
    list.scrollToIndex(firstVisibleIndex, "start");
  }

  stats.update();
  updateContext();
}

// =============================================================================
// Contact detail (panel) — shows selected contact
// =============================================================================

const detailEl = document.getElementById("contact-detail");

function showContactDetail(contact) {
  detailEl.innerHTML = `
    <div class="panel-detail__header">
      <div class="contact-detail__avatar" style="background:${contact.color}">${contact.initials}</div>
      <div>
        <div class="panel-detail__name">${contact.firstName} ${contact.lastName}</div>
        <div class="contact-detail__role">${contact.role}</div>
      </div>
    </div>
    <div class="panel-detail__meta">
      <span>${contact.department} · ${contact.company}</span>
      <span>${contact.email}</span>
      <span>${contact.phone}</span>
      <span>${contact.city}, ${contact.country}</span>
    </div>
  `;
}

function clearContactDetail() {
  detailEl.innerHTML = `
    <span class="panel-detail__empty">Click a contact to see details</span>
  `;
}

// =============================================================================
// Footer — right side (contextual)
// =============================================================================

const ftGroups = document.getElementById("ft-groups");
const ftHeaders = document.getElementById("ft-headers");

export function updateContext() {
  ftGroups.textContent = sortedGroups.length;
  ftHeaders.textContent = currentHeaderMode;
}

// =============================================================================
// Distribution chart (rendered once in panel)
// =============================================================================

const groupDistEl = document.getElementById("group-distribution");

const groupCounts = new Map();
for (const contact of contacts) {
  const letter = contact.lastName[0].toUpperCase();
  groupCounts.set(letter, (groupCounts.get(letter) || 0) + 1);
}

const maxGroupSize = Math.max(...[...groupCounts.values()]);

groupDistEl.innerHTML = `<div class="dist-grid">${sortedGroups
  .map(([letter]) => {
    const count = groupCounts.get(letter) || 0;
    const barHeight = Math.round((count / maxGroupSize) * 100);
    return `
      <div class="dist-col" title="${letter}: ${count} contacts">
        <div class="dist-bar-wrap">
          <div class="dist-bar" style="height:${barHeight}%"></div>
        </div>
        <span class="dist-letter">${letter}</span>
      </div>
    `;
  })
  .join("")}</div>`;

// =============================================================================
// Initialise
// =============================================================================

initLetterGrid();
createList();
/* Contact List — example styles */

/* ============================================================================
   vlist item overrides
   ============================================================================ */

#list-container .vlist-item {
    padding: 0;
    border-bottom: none;
}

#list-container .vlist-item[data-id^="__group_header_"] {
    cursor: default;
}

#list-container .vlist-item[data-id^="__group_header_"]:hover {
    background-color: var(--vlist-group-header-bg, #f3f4f6);
}

/* ============================================================================
   Group Header (inline & sticky)
   ============================================================================ */

.group-header {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 0 16px;
    height: 100%;
    background: var(--vlist-group-header-bg, #f3f4f6);
}

.group-header__letter {
    font-size: 13px;
    font-weight: 700;
    color: var(--accent-text);
    text-transform: uppercase;
    letter-spacing: 0.5px;
    min-width: 20px;
    text-align: center;
    flex-shrink: 0;
}

.group-header__line {
    flex: 1;
    height: 1px;
    background: var(--vlist-border, #e5e7eb);
}

/* ============================================================================
   Contact Item (64px)
   ============================================================================ */

.contact {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 16px;
    height: 100%;
}

.contact__avatar {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-weight: 600;
    font-size: 13px;
    flex-shrink: 0;
}

.contact__info {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 2px;
}

.contact__name {
    font-weight: 600;
    font-size: 14px;
    color: var(--vlist-text, #111827);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.contact__detail {
    font-size: 12px;
    color: var(--vlist-text-muted, #6b7280);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.contact__phone {
    font-size: 12px;
    color: var(--vlist-text-muted, #6b7280);
    white-space: nowrap;
    flex-shrink: 0;
}

@media (max-width: 600px) {
    .contact__phone {
        display: none;
    }
}

/* ============================================================================
   Letter Grid — jump-to-letter buttons in panel
   ============================================================================ */

.letter-grid {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
}

.letter-btn {
    width: 28px;
    height: 28px;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 1px solid var(--border);
    border-radius: var(--examples-radius, 6px);
    background: var(--bg);
    color: var(--text-muted);
    font-size: 11px;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.15s ease;
    padding: 0;
}

.letter-btn:hover {
    background: var(--accent);
    color: white;
    border-color: var(--accent);
}

.letter-btn:active {
    transform: scale(0.93);
}

/* ============================================================================
   Group Distribution Bar Chart (in panel)
   ============================================================================ */

.group-distribution {
    padding: 0;
}

.dist-grid {
    display: flex;
    gap: 2px;
    align-items: flex-end;
    height: 60px;
}

.dist-col {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 3px;
    min-width: 0;
}

.dist-bar-wrap {
    width: 100%;
    height: 44px;
    display: flex;
    align-items: flex-end;
}

.dist-bar {
    width: 100%;
    min-height: 2px;
    background: linear-gradient(
        180deg,
        var(--accent, #667eea) 0%,
        #764ba2 100%
    );
    border-radius: 2px 2px 0 0;
    transition: height 0.3s ease;
}

.dist-letter {
    font-size: 9px;
    font-weight: 600;
    color: var(--text-dim);
    text-transform: uppercase;
}

/* ============================================================================
   Contact Detail (panel) — selected contact card
   ============================================================================ */

.contact-detail__avatar {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-weight: 600;
    font-size: 13px;
    flex-shrink: 0;
}

.contact-detail__role {
    font-size: 12px;
    color: var(--text-muted);
}

/* ============================================================================
   Sticky header styling overrides
   ============================================================================ */

.vlist-sticky-header {
    display: flex;
    align-items: center;
}

.vlist-sticky-header .group-header {
    width: 100%;
}
<div class="container">
    <header>
        <h1>Contact List</h1>
        <p class="description">
            A–Z grouped contacts with sticky section headers using
            <code>withGroups</code>. Headers stick to the top and get pushed out
            by the next group — just like iOS Contacts.
        </p>
    </header>

    <div class="split-layout">
        <div class="split-main">
            <div id="list-container"></div>
        </div>

        <aside class="split-panel">
            <!-- Sections -->
            <section class="panel-section">
                <h3 class="panel-title">Headers</h3>

                <div class="panel-row">
                    <div class="panel-segmented" id="header-mode">
                        <button
                            class="panel-segmented__btn panel-segmented__btn--active"
                            data-mode="sticky"
                        >
                            Sticky
                        </button>
                        <button class="panel-segmented__btn" data-mode="inline">
                            Inline
                        </button>
                        <button class="panel-segmented__btn" data-mode="off">
                            Off
                        </button>
                    </div>
                </div>
            </section>

            <!-- Jump to Letter -->
            <section class="panel-section">
                <h3 class="panel-title">Jump to Letter</h3>
                <div class="panel-row">
                    <div class="letter-grid" id="letter-grid"></div>
                </div>
            </section>

            <!-- Navigation -->
            <section class="panel-section">
                <h3 class="panel-title">Navigation</h3>
                <div class="panel-row">
                    <div class="panel-btn-group">
                        <button
                            id="btn-first"
                            class="panel-btn panel-btn--icon"
                            title="First"
                        >
                            <i class="icon icon--up"></i>
                        </button>
                        <button
                            id="btn-middle"
                            class="panel-btn panel-btn--icon"
                            title="Middle"
                        >
                            <i class="icon icon--center"></i>
                        </button>
                        <button
                            id="btn-last"
                            class="panel-btn panel-btn--icon"
                            title="Last"
                        >
                            <i class="icon icon--down"></i>
                        </button>
                        <button
                            id="btn-random"
                            class="panel-btn panel-btn--icon"
                            title="Random group"
                        >
                            <i class="icon icon--shuffle"></i>
                        </button>
                    </div>
                </div>
            </section>

            <!-- Distribution -->
            <section class="panel-section">
                <h3 class="panel-title">Distribution</h3>
                <div class="group-distribution" id="group-distribution"></div>
            </section>

            <!-- Selected Contact -->
            <section class="panel-section">
                <h3 class="panel-title">Selected contact</h3>
                <div class="panel-detail" id="contact-detail">
                    <span class="panel-detail__empty"
                        >Click a contact to see details</span
                    >
                </div>
            </section>
        </aside>
    </div>

    <footer class="example-footer" id="example-footer">
        <div class="example-footer__left">
            <span class="example-footer__stat">
                <strong id="ft-progress">0%</strong>
            </span>
            <span class="example-footer__stat">
                <span id="ft-velocity">0.00</span> /
                <strong id="ft-velocity-avg">0.00</strong>
                <span class="example-footer__unit">px/ms</span>
            </span>
            <span class="example-footer__stat">
                <span id="ft-dom">0</span> /
                <strong id="ft-total">0</strong>
                <span class="example-footer__unit">items</span>
            </span>
        </div>
        <div class="example-footer__right">
            <span class="example-footer__stat">
                <strong id="ft-groups">0</strong>
                <span class="example-footer__unit">groups</span>
            </span>
            <span class="example-footer__stat">
                <strong id="ft-headers">sticky</strong>
            </span>
        </div>
    </footer>
</div>