{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table",
  "type": "registry:block",
  "title": "Data Table",
  "description": "A real table that sorts with a FLIP spring: rows fly to their new places, far movers ride above the rest and rows from other pages glide in. One highlight bar springs between rows, the header sticks with a hairline shadow, and paging cross-slides under a sliding pill.",
  "docs": "Data Table was added to public/vividsites/data-table.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/data-table",
  "categories": [
    "ui",
    "data-display"
  ],
  "files": [
    {
      "path": "components/vividsites/data-table.html",
      "type": "registry:file",
      "target": "public/vividsites/data-table.html",
      "content": "<!--\nvs-data-table: a real <table> that sorts, pages and selects with physical motion.\n  Sorting re-orders rows with a FLIP spring: every row flies from where it was to where it lands,\n  far movers lag a beat and ride above the others, rows arriving from other pages rise in.\n  The sort chevron swings on a spring (3 states: ascending, descending, off). Hover or keyboard\n  focus moves ONE highlight bar that springs from row to row. The header is sticky inside a\n  fixed-height scroll area and gains a hairline shadow once the body scrolls under it.\n  Pagination: a sliding active pill, prev/next, \"1–10 of 36\", a rows-per-page select, and page\n  changes cross-slide the rows. Optional row checkboxes with a tri-state header box.\n\n  Markup: a plain table inside the root. Without JS it is still a complete, readable table.\n    <div class=\"vs-data-table\" data-page-size=\"10\" data-select>\n      <div class=\"vs-data-table-top\"> title / subtitle (optional) </div>\n      <table>\n        <caption>Shipments</caption>                        (visually hidden, names the table)\n        <thead><tr><th data-type=\"text\">Customer</th><th data-type=\"number\" data-align=\"end\">Weight</th>…</tr></thead>\n        <tbody><tr><td>…</td><td data-sort=\"4280\">4,280</td>…</tr></tbody>\n      </table>\n    </div>\n  <th> attributes:\n    data-type       \"text\" (default) | \"number\" | \"date\"   how the column compares\n    data-align      \"end\" right-aligns the column (numbers)\n    data-nosort     makes a column not sortable\n  <td data-sort=\"…\"> the value to sort by when it differs from the text (ISO dates, raw numbers, a rank).\n  Status chips: <span class=\"vs-data-table-chip\" data-tone=\"transit|delivered|customs|scheduled\">…</span>\n  Root attributes:\n    data-page-size                  rows per page (default 10); data-page-sizes=\"10,20,50\" for the select\n    data-select                     adds row checkboxes + a tri-state select-all for the page\n    data-vs-data-table-density      \"comfy\" (default) | \"compact\"\n    data-vs-data-table-stripes      \"off\" (default) | \"on\"\n    data-vs-data-table-theme        \"light\" (default) | \"dark\"\n  CSS variables (on .vs-data-table): accent, bg, ink, muted, line, radius, row height, scroll-area\n    height, font and mono font; see the top of the style block.\n  JS:  el.vsDataTable.sort(columnIndex, \"asc\"|\"desc\"|null)   .page(n)   .pageSize(n)   .selected()\n  Events (bubble from the root): \"sortchange\" {column, dir}, \"pagechange\" {page, pages},\n    \"selectionchange\" {rows: [<tr>…]}\n  Keyboard: Tab to a header to sort (Enter/Space). Tab into the body, then ArrowUp/Down, Home/End\n    move the row focus, PageUp/PageDown change page, Space toggles the row's checkbox.\n  Reduced motion: rows, bar, chevrons and pill move instantly.\n-->\n<style>\n.vs-data-table {\n  --vs-data-table-accent: #2b59ff;\n  --vs-data-table-font: inherit;\n  --vs-data-table-mono: ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace;\n  --vs-data-table-bg: #ffffff;                    /* the card */\n  --vs-data-table-ink: #15161a;\n  --vs-data-table-muted: rgba(21,22,26,.54);\n  --vs-data-table-line: rgba(21,22,26,.085);      /* hairlines */\n  --vs-data-table-stripe: rgba(21,22,26,.024);\n  --vs-data-table-radius: 16px;\n  --vs-data-table-row: 46px;                      /* body row height (compact: 36px) */\n  --vs-data-table-height: 470px;                  /* max height of the scroll area; header sticks inside */\n  --vs-data-table-min: 640px;                     /* narrower than this (or its content) it scrolls sideways */\n  --vs-data-table-good: #177a4c;\n  --vs-data-table-warn: #b8540b;\n  --vs-data-table-idle: #6b6f78;\n\n  position: relative; display: flex; flex-direction: column; min-width: 0;\n  font-family: var(--vs-data-table-font); font-size: 14px; line-height: 1.3; color: var(--vs-data-table-ink);\n  background: var(--vs-data-table-bg); border-radius: var(--vs-data-table-radius);\n  box-shadow: 0 0 0 .5px rgba(20,22,30,.1), inset 0 .5px 0 rgba(255,255,255,.9),\n    0 1px 2px rgba(20,22,30,.04), 0 6px 16px -6px rgba(20,24,40,.08), 0 28px 60px -22px rgba(20,24,40,.2);\n  -webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums; isolation: isolate;\n}\n.vs-data-table *, .vs-data-table *::before, .vs-data-table *::after { box-sizing: border-box; }\n.vs-data-table[data-vs-data-table-density=\"compact\"] { --vs-data-table-row: 36px; font-size: 13px; }\n.vs-data-table[data-vs-data-table-theme=\"dark\"] {\n  --vs-data-table-bg: #16181d; --vs-data-table-ink: #eceef3; --vs-data-table-muted: rgba(236,238,243,.5);\n  --vs-data-table-line: rgba(255,255,255,.075); --vs-data-table-stripe: rgba(255,255,255,.022);\n  --vs-data-table-good: #4fd494; --vs-data-table-warn: #ffa654; --vs-data-table-idle: #9aa0ab;\n  box-shadow: 0 0 0 .5px rgba(255,255,255,.1), inset 0 .5px 0 rgba(255,255,255,.07),\n    0 2px 4px rgba(0,0,0,.3), 0 30px 70px -24px rgba(0,0,0,.7);\n}\n\n/* title row */\n.vs-data-table-top { display: flex; align-items: baseline; gap: 12px; padding: 18px 22px 14px; }\n.vs-data-table-title { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -.01em; }\n.vs-data-table-sub { font-size: 12.5px; color: var(--vs-data-table-muted); }\n\n/* scroll area: sticky header lives inside it */\n.vs-data-table-scroll {\n  position: relative; overflow: auto; max-height: var(--vs-data-table-height);\n  overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--vs-data-table-line) transparent;\n  border-top: .5px solid var(--vs-data-table-line);\n}\n/* more rows below: the bottom edge fades so the cut row reads as \"scroll for more\" */\n.vs-data-table-scroll.is-more { -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 34px), rgba(0,0,0,.12)); mask-image: linear-gradient(to bottom, #000 calc(100% - 34px), rgba(0,0,0,.12)); }\n.vs-data-table table {\n  position: relative; width: 100%; min-width: var(--vs-data-table-min);\n  border-collapse: separate; border-spacing: 0; table-layout: auto;\n}\n.vs-data-table table.is-fixed { table-layout: fixed; }\n.vs-data-table caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n.vs-data-table th, .vs-data-table td { padding: 0 16px; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.vs-data-table :is(th, td):first-child { padding-left: 22px; }\n.vs-data-table :is(th, td):last-child { padding-right: 22px; }\n.vs-data-table :is(th, td)[data-align=\"end\"] { text-align: right; }\n\n/* header */\n.vs-data-table thead th {\n  position: sticky; top: 0; z-index: 12; height: 40px;\n  font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--vs-data-table-muted);\n  background: var(--vs-data-table-bg); box-shadow: inset 0 -.5px 0 var(--vs-data-table-line);\n  transition: box-shadow .25s cubic-bezier(.2,0,0,1);\n}\n.vs-data-table.is-scrolled thead th { box-shadow: inset 0 -.5px 0 var(--vs-data-table-line), 0 10px 14px -12px rgba(10,14,30,.28); }\n.vs-data-table[data-vs-data-table-theme=\"dark\"].is-scrolled thead th { box-shadow: inset 0 -.5px 0 var(--vs-data-table-line), 0 10px 16px -10px rgba(0,0,0,.7); }\n.vs-data-table-sort {\n  all: unset; box-sizing: border-box; display: inline-flex; align-items: center; gap: 6px; max-width: 100%;\n  height: 26px; padding: 0 8px; margin: 0 -8px; border-radius: 7px; cursor: pointer; color: inherit;\n  transition: background-color .15s cubic-bezier(.2,0,0,1), color .15s;\n}\n[data-align=\"end\"] > .vs-data-table-sort { flex-direction: row-reverse; }\n.vs-data-table-sort:hover { background: color-mix(in srgb, var(--vs-data-table-ink) 5%, transparent); color: var(--vs-data-table-ink); }\n.vs-data-table-sort:active { background: color-mix(in srgb, var(--vs-data-table-ink) 8%, transparent); }\n.vs-data-table-sort:focus-visible { box-shadow: 0 0 0 2px var(--vs-data-table-accent); }\nth[aria-sort=\"ascending\"] .vs-data-table-sort, th[aria-sort=\"descending\"] .vs-data-table-sort { color: var(--vs-data-table-ink); }\n.vs-data-table-chev { width: 12px; height: 12px; flex: none; opacity: 0; transition: opacity .2s; will-change: transform; }\n.vs-data-table-sort:hover .vs-data-table-chev { opacity: .45; }\nth[aria-sort=\"ascending\"] .vs-data-table-chev, th[aria-sort=\"descending\"] .vs-data-table-chev { opacity: 1; color: var(--vs-data-table-accent); }\n\n/* body rows: transparent so the highlight bar shows underneath */\n.vs-data-table tbody tr { position: relative; outline: none; }\n.vs-data-table tbody td {\n  height: var(--vs-data-table-row); color: color-mix(in srgb, var(--vs-data-table-ink) 88%, transparent);\n  background: linear-gradient(var(--_tint, transparent), var(--_tint, transparent)), linear-gradient(var(--_stripe, transparent), var(--_stripe, transparent)), var(--_fill, transparent);\n  box-shadow: inset 0 -.5px 0 var(--vs-data-table-line);\n  transition: color .2s;\n}\n.vs-data-table tbody tr:last-child td { box-shadow: none; }\n.vs-data-table[data-vs-data-table-stripes=\"on\"] tbody tr:nth-child(even) { --_stripe: var(--vs-data-table-stripe); }\n.vs-data-table tbody tr.is-moving { --_fill: var(--vs-data-table-bg); }\n.vs-data-table tbody tr.is-sel { --_tint: color-mix(in srgb, var(--vs-data-table-accent) 6%, transparent); }\n.vs-data-table tbody td:first-child { font-family: var(--vs-data-table-mono); font-size: .86em; letter-spacing: .01em; color: var(--vs-data-table-muted); font-variant-numeric: tabular-nums slashed-zero; transition: color .2s; }\n.vs-data-table tbody tr.is-hot td { color: var(--vs-data-table-ink); }\n.vs-data-table tbody tr.is-hot td:first-child { color: var(--vs-data-table-accent); }\n.vs-data-table[data-select] tbody td:nth-child(2) { font-family: var(--vs-data-table-mono); font-size: .86em; letter-spacing: .01em; color: var(--vs-data-table-muted); font-variant-numeric: tabular-nums slashed-zero; }\n.vs-data-table[data-select] tbody tr.is-hot td:nth-child(2) { color: var(--vs-data-table-accent); }\n.vs-data-table[data-select] tbody td:first-child { font-family: inherit; }\n\n/* the ONE highlight bar (hover / keyboard focus). It floats above the rows and tints them with a\n   blend, so it stays visible over rows that are mid-flight; the header (z 12) covers it. */\n.vs-data-table-hl { position: absolute; left: 0; top: 0; z-index: 10; pointer-events: none; will-change: transform; mix-blend-mode: multiply; }\n.vs-data-table[data-vs-data-table-theme=\"dark\"] .vs-data-table-hl { mix-blend-mode: screen; }\n.vs-data-table-hl i {\n  position: absolute; inset: 1px 8px; border-radius: 10px;\n  background: color-mix(in srgb, var(--vs-data-table-accent) 6.5%, var(--vs-data-table-bg));\n  box-shadow: 0 0 0 .5px color-mix(in srgb, var(--vs-data-table-accent) 22%, transparent),\n    0 1px 2px color-mix(in srgb, var(--vs-data-table-accent) 10%, transparent),\n    0 8px 18px -10px color-mix(in srgb, var(--vs-data-table-accent) 45%, transparent);\n  opacity: 0; transform: scale(.985, .8);\n  transition: opacity .18s cubic-bezier(.2,0,0,1), transform .35s cubic-bezier(.34,1.56,.64,1), box-shadow .15s;\n}\n.vs-data-table-hl i::before { content: \"\"; position: absolute; left: 0; top: 30%; bottom: 30%; width: 2.5px; border-radius: 2px; background: var(--vs-data-table-accent); }\n.vs-data-table-hl.is-on i { opacity: 1; transform: none; }\n.vs-data-table-hl.is-ring i { box-shadow: 0 0 0 1.5px var(--vs-data-table-accent), 0 8px 18px -10px color-mix(in srgb, var(--vs-data-table-accent) 45%, transparent); }\n.vs-data-table[data-vs-data-table-theme=\"dark\"] .vs-data-table-hl i { background: color-mix(in srgb, var(--vs-data-table-accent) 16%, #000); box-shadow: 0 0 0 .5px color-mix(in srgb, var(--vs-data-table-accent) 45%, #000); }\n.vs-data-table[data-vs-data-table-theme=\"dark\"] .vs-data-table-hl.is-ring i { box-shadow: 0 0 0 1.5px var(--vs-data-table-accent); }\n\n/* status chips */\n.vs-data-table-chip {\n  --c: var(--vs-data-table-idle);\n  display: inline-flex; align-items: center; gap: 7px; height: 23px; padding: 0 10px 0 9px; border-radius: 999px;\n  font-size: 12px; font-weight: 600; letter-spacing: -.005em; line-height: 1;\n  color: color-mix(in srgb, var(--c) 88%, var(--vs-data-table-ink));\n  background: color-mix(in srgb, var(--c) 9%, transparent);\n  box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--c) 24%, transparent);\n}\n.vs-data-table-chip { position: relative; }\n.vs-data-table-chip::before { content: \"\"; width: 6px; height: 6px; border-radius: 50%; background: var(--c); flex: none; }\n.vs-data-table-chip[data-tone=\"transit\"]::after {\n  content: \"\"; position: absolute; left: 9px; top: 50%; width: 6px; height: 6px; margin-top: -3px; border-radius: 50%;\n  box-shadow: 0 0 0 1px var(--c); opacity: 0; animation: vs-data-table-ping 2.4s cubic-bezier(.2,0,0,1) infinite;\n}\n.vs-data-table-chip[data-tone=\"transit\"] { --c: var(--vs-data-table-accent); }\n.vs-data-table-chip[data-tone=\"delivered\"] { --c: var(--vs-data-table-good); }\n.vs-data-table-chip[data-tone=\"customs\"] { --c: var(--vs-data-table-warn); }\n@keyframes vs-data-table-ping { 0% { transform: scale(1); opacity: .7; } 70%, 100% { transform: scale(2.6); opacity: 0; } }\n\n/* checkboxes (data-select) */\n.vs-data-table :is(th, td).vs-data-table-cb { width: 50px; padding: 0 0 0 22px; overflow: visible; }\n.vs-data-table-check {\n  appearance: none; -webkit-appearance: none; margin: 0; display: block; width: 16px; height: 16px; border-radius: 5px; cursor: pointer;\n  background: var(--vs-data-table-bg) center / 10px 10px no-repeat;\n  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-data-table-ink) 26%, transparent), 0 1px 1px rgba(0,0,0,.04);\n  transition: background-color .15s, box-shadow .15s, transform .3s cubic-bezier(.34,1.56,.64,1);\n}\n.vs-data-table-check:hover { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-data-table-ink) 45%, transparent); }\n.vs-data-table-check:active { transform: scale(.88); }\n.vs-data-table-check:focus-visible { outline: 2px solid var(--vs-data-table-accent); outline-offset: 2px; }\n.vs-data-table-check:checked, .vs-data-table-check:indeterminate {\n  background-color: var(--vs-data-table-accent); box-shadow: inset 0 0 0 .5px rgba(0,0,0,.18), 0 1px 3px color-mix(in srgb, var(--vs-data-table-accent) 40%, transparent);\n  background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' fill='none' stroke='white' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 5.2 4.1 7.3 8 2.8'/%3E%3C/svg%3E\");\n  animation: vs-data-table-pop .32s cubic-bezier(.34,1.56,.64,1);\n}\n.vs-data-table-check:indeterminate { background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' fill='none' stroke='white' stroke-width='1.7' stroke-linecap='round'%3E%3Cpath d='M2.5 5h5'/%3E%3C/svg%3E\"); }\n@keyframes vs-data-table-pop { 0% { transform: scale(.7); } }\n\n/* sticky first column once the table scrolls sideways (narrow screens) */\n.vs-data-table.is-x :is(th, td).vs-data-table-stick { position: sticky; left: var(--_l, 0px); z-index: 9; --_fill: var(--vs-data-table-bg); }\n.vs-data-table.is-x thead th.vs-data-table-stick { z-index: 13; }\n.vs-data-table.is-x.is-xs :is(th, td).vs-data-table-edge { box-shadow: inset -.5px 0 0 var(--vs-data-table-line), inset 0 -.5px 0 var(--vs-data-table-line), 8px 0 12px -10px rgba(10,14,30,.3); }\n.vs-data-table.is-x.is-xs thead th.vs-data-table-edge { box-shadow: inset -.5px 0 0 var(--vs-data-table-line), inset 0 -.5px 0 var(--vs-data-table-line), 8px 0 12px -10px rgba(10,14,30,.3); }\n\n/* footer: range, rows per page, pager */\n.vs-data-table-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 18px; padding: 12px 16px 12px 22px; border-top: .5px solid var(--vs-data-table-line); font-size: 13px; color: var(--vs-data-table-muted); }\n.vs-data-table-range b { font-weight: 600; color: var(--vs-data-table-ink); }\n.vs-data-table-selc { display: inline-flex; align-items: center; height: 22px; padding: 0 9px; margin-left: 8px; border-radius: 999px; font-size: 12px; font-weight: 600; color: var(--vs-data-table-accent); background: color-mix(in srgb, var(--vs-data-table-accent) 10%, transparent); animation: vs-data-table-pop .35s cubic-bezier(.34,1.56,.64,1); }\n.vs-data-table-selc[hidden] { display: none; }\n.vs-data-table-grow { flex: 1; }\n.vs-data-table-size { display: inline-flex; align-items: center; gap: 8px; }\n.vs-data-table-size select {\n  appearance: none; -webkit-appearance: none; font: inherit; font-weight: 600; color: var(--vs-data-table-ink); cursor: pointer;\n  height: 30px; padding: 0 26px 0 10px; border: 0; border-radius: 8px;\n  background: color-mix(in srgb, var(--vs-data-table-ink) 4.5%, transparent) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' fill='none' stroke='%23888' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 4.2 5 6.2 7 4.2'/%3E%3C/svg%3E\") right 8px center / 10px no-repeat;\n  box-shadow: inset 0 0 0 .5px var(--vs-data-table-line);\n}\n.vs-data-table-size select:focus-visible { outline: 2px solid var(--vs-data-table-accent); outline-offset: 1px; }\n.vs-data-table-size option { color: #15161a; background: #fff; }\n.vs-data-table-pager { display: flex; align-items: center; gap: 4px; }\n.vs-data-table-pages { position: relative; display: flex; gap: 2px; }\n.vs-data-table-pager button {\n  all: unset; box-sizing: border-box; position: relative; z-index: 1; display: inline-grid; place-items: center;\n  min-width: 32px; height: 32px; padding: 0 6px; border-radius: 9px; cursor: pointer;\n  font-weight: 600; font-size: 13px; color: var(--vs-data-table-muted); font-variant-numeric: tabular-nums;\n  transition: color .2s, background-color .15s, transform .25s cubic-bezier(.34,1.56,.64,1);\n}\n.vs-data-table-pager button:hover:not(:disabled):not([aria-current]) { color: var(--vs-data-table-ink); background: color-mix(in srgb, var(--vs-data-table-ink) 5%, transparent); }\n.vs-data-table-pager button:active:not(:disabled) { transform: scale(.92); }\n.vs-data-table-pager button:focus-visible { box-shadow: 0 0 0 2px var(--vs-data-table-accent); }\n.vs-data-table-pager button:disabled { opacity: .35; cursor: default; }\n.vs-data-table-pager button[aria-current] { color: #fff; }\n.vs-data-table-pager svg { width: 14px; height: 14px; }\n.vs-data-table-gap { display: inline-grid; place-items: center; min-width: 20px; letter-spacing: .1em; }\n.vs-data-table-pill {\n  position: absolute; left: 0; top: 0; height: 32px; border-radius: 9px; z-index: 0; pointer-events: none;\n  background: var(--vs-data-table-accent); will-change: transform, width;\n  box-shadow: inset 0 .5px 0 rgba(255,255,255,.28), 0 0 0 .5px rgba(0,0,0,.12), 0 2px 8px -2px color-mix(in srgb, var(--vs-data-table-accent) 60%, transparent);\n}\n.vs-data-table-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n.vs-data-table table.vs-data-table-ghost { position: absolute; left: 0; top: 0; z-index: -1; pointer-events: none; }\n.vs-data-table-ghost thead { visibility: hidden; }\n@media (max-width: 560px) {\n  .vs-data-table-top { padding: 16px 16px 12px; flex-wrap: wrap; row-gap: 2px; }\n  .vs-data-table :is(th, td):first-child { padding-left: 16px; }\n  .vs-data-table :is(th, td).vs-data-table-cb { width: 42px; padding-left: 16px; }\n  .vs-data-table-foot { padding: 10px 12px 12px 16px; gap: 10px 12px; }\n  .vs-data-table-size span { display: none; }\n  .vs-data-table-pager { width: 100%; justify-content: space-between; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .vs-data-table-chip::after, .vs-data-table-check, .vs-data-table-selc { animation: none !important; }\n  .vs-data-table-hl i { transition: opacity .1s; }\n}\n</style>\n\n<div class=\"vs-data-table\" data-page-size=\"10\" data-page-sizes=\"10,20,50\" data-select>\n  <div class=\"vs-data-table-top\">\n    <h3 class=\"vs-data-table-title\">Shipments</h3>\n    <span class=\"vs-data-table-sub\">Harrow Freight · Q3 2026</span>\n  </div>\n  <div class=\"vs-data-table-scroll\">\n    <table>\n      <caption>Shipments, Harrow Freight, Q3 2026</caption>\n      <thead>\n        <tr>\n          <th data-type=\"number\">Shipment</th>\n          <th>Customer</th>\n          <th>Destination</th>\n          <th data-type=\"number\">Status</th>\n          <th data-type=\"number\" data-align=\"end\">Weight kg</th>\n          <th data-type=\"number\" data-align=\"end\">Value</th>\n          <th data-type=\"date\" data-align=\"end\">Shipped</th>\n        </tr>\n      </thead>\n      <tbody>\n      <tr><td data-sort=\"24195\">SHP-24195</td><td>Greyhaven Audio</td><td>Valparaíso, CL</td><td data-sort=\"4\"><span class=\"vs-data-table-chip\" data-tone=\"scheduled\">Scheduled</span></td><td data-sort=\"15630\">15,630</td><td data-sort=\"79220\">$79,220</td><td data-sort=\"2026-09-30\">30 Sep 2026</td></tr>\n      <tr><td data-sort=\"24193\">SHP-24193</td><td>Lowtide Coffee Co.</td><td>Busan, KR</td><td data-sort=\"4\"><span class=\"vs-data-table-chip\" data-tone=\"scheduled\">Scheduled</span></td><td data-sort=\"1660\">1,660</td><td data-sort=\"7560\">$7,560</td><td data-sort=\"2026-09-28\">28 Sep 2026</td></tr>\n      <tr><td data-sort=\"24190\">SHP-24190</td><td>Northmere Textiles</td><td>Rotterdam, NL</td><td data-sort=\"4\"><span class=\"vs-data-table-chip\" data-tone=\"scheduled\">Scheduled</span></td><td data-sort=\"4940\">4,940</td><td data-sort=\"49090\">$49,090</td><td data-sort=\"2026-09-27\">27 Sep 2026</td></tr>\n      <tr><td data-sort=\"24187\">SHP-24187</td><td>Palisade Cycles</td><td>Antwerp, BE</td><td data-sort=\"4\"><span class=\"vs-data-table-chip\" data-tone=\"scheduled\">Scheduled</span></td><td data-sort=\"10200\">10,200</td><td data-sort=\"100080\">$100,080</td><td data-sort=\"2026-09-26\">26 Sep 2026</td></tr>\n      <tr><td data-sort=\"24184\">SHP-24184</td><td>Willowmere Linen</td><td>Melbourne, AU</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"5520\">5,520</td><td data-sort=\"54990\">$54,990</td><td data-sort=\"2026-09-24\">24 Sep 2026</td></tr>\n      <tr><td data-sort=\"24179\">SHP-24179</td><td>Quarry Street Press</td><td>Rotterdam, NL</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"2310\">2,310</td><td data-sort=\"8630\">$8,630</td><td data-sort=\"2026-09-24\">24 Sep 2026</td></tr>\n      <tr><td data-sort=\"24178\">SHP-24178</td><td>Greyhaven Audio</td><td>Halifax, CA</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"10470\">10,470</td><td data-sort=\"73120\">$73,120</td><td data-sort=\"2026-09-22\">22 Sep 2026</td></tr>\n      <tr><td data-sort=\"24175\">SHP-24175</td><td>Brackenridge Tools</td><td>Aarhus, DK</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"990\">990</td><td data-sort=\"3360\">$3,360</td><td data-sort=\"2026-09-21\">21 Sep 2026</td></tr>\n      <tr><td data-sort=\"24170\">SHP-24170</td><td>Saltbox Provisions</td><td>Gdańsk, PL</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"5090\">5,090</td><td data-sort=\"32900\">$32,900</td><td data-sort=\"2026-09-21\">21 Sep 2026</td></tr>\n      <tr><td data-sort=\"24169\">SHP-24169</td><td>Saltbox Provisions</td><td>Yokohama, JP</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"2070\">2,070</td><td data-sort=\"18180\">$18,180</td><td data-sort=\"2026-09-20\">20 Sep 2026</td></tr>\n      <tr><td data-sort=\"24166\">SHP-24166</td><td>Lowtide Coffee Co.</td><td>Aarhus, DK</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"600\">600</td><td data-sort=\"4970\">$4,970</td><td data-sort=\"2026-09-19\">19 Sep 2026</td></tr>\n      <tr><td data-sort=\"24161\">SHP-24161</td><td>Oriel Instruments</td><td>Melbourne, AU</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"440\">440</td><td data-sort=\"4130\">$4,130</td><td data-sort=\"2026-09-17\">17 Sep 2026</td></tr>\n      <tr><td data-sort=\"24160\">SHP-24160</td><td>Lowtide Coffee Co.</td><td>Piraeus, GR</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"9740\">9,740</td><td data-sort=\"103900\">$103,900</td><td data-sort=\"2026-09-16\">16 Sep 2026</td></tr>\n      <tr><td data-sort=\"24157\">SHP-24157</td><td>Brackenridge Tools</td><td>Rotterdam, NL</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"7860\">7,860</td><td data-sort=\"29400\">$29,400</td><td data-sort=\"2026-09-14\">14 Sep 2026</td></tr>\n      <tr><td data-sort=\"24153\">SHP-24153</td><td>Fenwick Ceramics</td><td>Yokohama, JP</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"3880\">3,880</td><td data-sort=\"36590\">$36,590</td><td data-sort=\"2026-09-14\">14 Sep 2026</td></tr>\n      <tr><td data-sort=\"24151\">SHP-24151</td><td>Greyhaven Audio</td><td>Aarhus, DK</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"11250\">11,250</td><td data-sort=\"63460\">$63,460</td><td data-sort=\"2026-09-12\">12 Sep 2026</td></tr>\n      <tr><td data-sort=\"24147\">SHP-24147</td><td>Lowtide Coffee Co.</td><td>Piraeus, GR</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"2730\">2,730</td><td data-sort=\"33490\">$33,490</td><td data-sort=\"2026-09-11\">11 Sep 2026</td></tr>\n      <tr><td data-sort=\"24143\">SHP-24143</td><td>Willowmere Linen</td><td>Yokohama, JP</td><td data-sort=\"3\"><span class=\"vs-data-table-chip\" data-tone=\"customs\">Held at customs</span></td><td data-sort=\"16750\">16,750</td><td data-sort=\"196990\">$196,990</td><td data-sort=\"2026-09-10\">10 Sep 2026</td></tr>\n      <tr><td data-sort=\"24141\">SHP-24141</td><td>Kestrel Outfitters</td><td>Busan, KR</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"4720\">4,720</td><td data-sort=\"54220\">$54,220</td><td data-sort=\"2026-09-08\">08 Sep 2026</td></tr>\n      <tr><td data-sort=\"24137\">SHP-24137</td><td>Marrow &amp; Finch</td><td>Halifax, CA</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"3690\">3,690</td><td data-sort=\"40180\">$40,180</td><td data-sort=\"2026-09-07\">07 Sep 2026</td></tr>\n      <tr><td data-sort=\"24136\">SHP-24136</td><td>Saltbox Provisions</td><td>Singapore, SG</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"660\">660</td><td data-sort=\"4660\">$4,660</td><td data-sort=\"2026-09-06\">06 Sep 2026</td></tr>\n      <tr><td data-sort=\"24133\">SHP-24133</td><td>Tamsin Florals</td><td>Veracruz, MX</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"3630\">3,630</td><td data-sort=\"38290\">$38,290</td><td data-sort=\"2026-09-04\">04 Sep 2026</td></tr>\n      <tr><td data-sort=\"24128\">SHP-24128</td><td>Kestrel Outfitters</td><td>Antwerp, BE</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"4870\">4,870</td><td data-sort=\"37720\">$37,720</td><td data-sort=\"2026-09-03\">03 Sep 2026</td></tr>\n      <tr><td data-sort=\"24127\">SHP-24127</td><td>Marrow &amp; Finch</td><td>Veracruz, MX</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"17700\">17,700</td><td data-sort=\"88200\">$88,200</td><td data-sort=\"2026-09-02\">02 Sep 2026</td></tr>\n      <tr><td data-sort=\"24124\">SHP-24124</td><td>Willowmere Linen</td><td>Durban, ZA</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"9540\">9,540</td><td data-sort=\"96240\">$96,240</td><td data-sort=\"2026-09-01\">01 Sep 2026</td></tr>\n      <tr><td data-sort=\"24119\">SHP-24119</td><td>Lowtide Coffee Co.</td><td>Singapore, SG</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"310\">310</td><td data-sort=\"2910\">$2,910</td><td data-sort=\"2026-08-31\">31 Aug 2026</td></tr>\n      <tr><td data-sort=\"24116\">SHP-24116</td><td>Oriel Instruments</td><td>Piraeus, GR</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"1230\">1,230</td><td data-sort=\"12190\">$12,190</td><td data-sort=\"2026-08-30\">30 Aug 2026</td></tr>\n      <tr><td data-sort=\"24113\">SHP-24113</td><td>Saltbox Provisions</td><td>Aarhus, DK</td><td data-sort=\"1\"><span class=\"vs-data-table-chip\" data-tone=\"transit\">In transit</span></td><td data-sort=\"4000\">4,000</td><td data-sort=\"48570\">$48,570</td><td data-sort=\"2026-08-29\">29 Aug 2026</td></tr>\n      <tr><td data-sort=\"24112\">SHP-24112</td><td>Brackenridge Tools</td><td>Rotterdam, NL</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"17480\">17,480</td><td data-sort=\"60250\">$60,250</td><td data-sort=\"2026-08-28\">28 Aug 2026</td></tr>\n      <tr><td data-sort=\"24107\">SHP-24107</td><td>Halden &amp; Roe</td><td>Busan, KR</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"15860\">15,860</td><td data-sort=\"96830\">$96,830</td><td data-sort=\"2026-08-27\">27 Aug 2026</td></tr>\n      <tr><td data-sort=\"24105\">SHP-24105</td><td>Northmere Textiles</td><td>Tangier, MA</td><td data-sort=\"3\"><span class=\"vs-data-table-chip\" data-tone=\"customs\">Held at customs</span></td><td data-sort=\"15380\">15,380</td><td data-sort=\"182820\">$182,820</td><td data-sort=\"2026-08-26\">26 Aug 2026</td></tr>\n      <tr><td data-sort=\"24102\">SHP-24102</td><td>Ashcombe Paper</td><td>Durban, ZA</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"11810\">11,810</td><td data-sort=\"59710\">$59,710</td><td data-sort=\"2026-08-25\">25 Aug 2026</td></tr>\n      <tr><td data-sort=\"24100\">SHP-24100</td><td>Willowmere Linen</td><td>Veracruz, MX</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"14330\">14,330</td><td data-sort=\"48920\">$48,920</td><td data-sort=\"2026-08-25\">25 Aug 2026</td></tr>\n      <tr><td data-sort=\"24095\">SHP-24095</td><td>Palisade Cycles</td><td>Aarhus, DK</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"5320\">5,320</td><td data-sort=\"55950\">$55,950</td><td data-sort=\"2026-08-23\">23 Aug 2026</td></tr>\n      <tr><td data-sort=\"24093\">SHP-24093</td><td>Brackenridge Tools</td><td>Aarhus, DK</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"8980\">8,980</td><td data-sort=\"92780\">$92,780</td><td data-sort=\"2026-08-22\">22 Aug 2026</td></tr>\n      <tr><td data-sort=\"24091\">SHP-24091</td><td>Kestrel Outfitters</td><td>Gdańsk, PL</td><td data-sort=\"2\"><span class=\"vs-data-table-chip\" data-tone=\"delivered\">Delivered</span></td><td data-sort=\"9270\">9,270</td><td data-sort=\"111510\">$111,510</td><td data-sort=\"2026-08-21\">21 Aug 2026</td></tr>\n      </tbody>\n    </table>\n  </div>\n</div>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const NS = 'http://www.w3.org/2000/svg';\n  const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });\n  // a critically-tuned spring: semi-implicit Euler with fixed 1/240 s substeps\n  const spring = (x, k, zeta) => ({ x, v: 0, t: x, k, c: 2 * Math.sqrt(k) * zeta });\n  const step = (s, dt, eps = .05) => {\n    const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;\n    for (let i = 0; i < n; i++) { s.v += (-s.k * (s.x - s.t) - s.c * s.v) * h; s.x += s.v * h; }\n    if (Math.abs(s.v) < eps * 4 && Math.abs(s.x - s.t) < eps) { s.x = s.t; s.v = 0; return false; }\n    return true;\n  };\n\n  function init(root) {\n    if (root.vsDataTable) return;\n    const table = root.querySelector('table'); if (!table || !table.tBodies[0]) return;\n    const tbody = table.tBodies[0];\n    let scroll = table.closest('.vs-data-table-scroll');\n    if (!scroll) { scroll = document.createElement('div'); scroll.className = 'vs-data-table-scroll'; table.before(scroll); scroll.appendChild(table); }\n    const headRow = table.tHead.rows[0];\n    const all = [...tbody.rows]; // original order\n    const selectable = root.hasAttribute('data-select');\n    const sizes = (root.dataset.pageSizes || '10,20,50').split(',').map(Number).filter(Boolean);\n    let size = +root.dataset.pageSize || sizes[0] || 10, page = 0;\n    let sortCol = -1, sortDir = null;\n\n    // alignment from the header onto every cell; first column sticks on narrow screens\n    const ths = [...headRow.cells];\n    ths.forEach((th, i) => { if (th.dataset.align) all.forEach((tr) => tr.cells[i] && (tr.cells[i].dataset.align = th.dataset.align)); });\n\n    // checkboxes\n    let headCb = null;\n    const sel = new Set();\n    if (selectable) {\n      const th = document.createElement('th'); th.className = 'vs-data-table-cb';\n      headCb = document.createElement('input'); headCb.type = 'checkbox'; headCb.className = 'vs-data-table-check'; headCb.setAttribute('aria-label', 'Select all rows on this page');\n      th.appendChild(headCb); headRow.prepend(th);\n      all.forEach((tr, i) => {\n        const td = document.createElement('td'); td.className = 'vs-data-table-cb';\n        const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'vs-data-table-check'; cb.tabIndex = -1;\n        cb.setAttribute('aria-label', 'Select ' + (tr.cells[0] ? tr.cells[0].textContent.trim() : 'row ' + (i + 1)));\n        td.appendChild(cb); tr.prepend(td);\n      });\n    }\n    const off = selectable ? 1 : 0; // column offset\n    [...headRow.cells].forEach((c, i) => { if (i <= off) { c.classList.add('vs-data-table-stick'); if (i === off) c.classList.add('vs-data-table-edge'); } });\n    all.forEach((tr) => [...tr.cells].forEach((c, i) => { if (i <= off) { c.classList.add('vs-data-table-stick'); if (i === off) c.classList.add('vs-data-table-edge'); } }));\n\n    // sort buttons + chevron springs\n    const chevs = [];\n    ths.forEach((th, i) => {\n      if (th.hasAttribute('data-nosort')) return;\n      th.setAttribute('aria-sort', 'none');\n      const b = document.createElement('button'); b.type = 'button'; b.className = 'vs-data-table-sort';\n      const label = document.createElement('span'); while (th.firstChild) label.appendChild(th.firstChild); b.appendChild(label);\n      const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('viewBox', '0 0 12 12'); svg.setAttribute('class', 'vs-data-table-chev'); svg.setAttribute('aria-hidden', 'true');\n      svg.innerHTML = '<path d=\"M3 7.5 6 4.5l3 3\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>';\n      b.appendChild(svg); th.appendChild(b);\n      const s = spring(0, 380, .42); chevs[i] = { el: svg, s };\n      b.addEventListener('click', () => { const dir = sortCol !== i ? 'asc' : sortDir === 'asc' ? 'desc' : sortDir === 'desc' ? null : 'asc'; sort(i, dir); });\n    });\n\n    // highlight bar\n    const hl = document.createElement('div'); hl.className = 'vs-data-table-hl'; hl.setAttribute('aria-hidden', 'true'); hl.innerHTML = '<i></i>';\n    scroll.prepend(hl);\n    const bar = spring(0, 520, .7); let hot = null, barShown = false;\n\n    // footer\n    const foot = document.createElement('div'); foot.className = 'vs-data-table-foot';\n    foot.innerHTML = `<span class=\"vs-data-table-range\"></span><span class=\"vs-data-table-grow\"></span>\n      <label class=\"vs-data-table-size\"><span>Rows per page</span><select aria-label=\"Rows per page\">${sizes.map((n) => `<option value=\"${n}\">${n}</option>`).join('')}</select></label>\n      <nav class=\"vs-data-table-pager\" aria-label=\"Pagination\">\n        <button type=\"button\" data-go=\"-1\" aria-label=\"Previous page\"><svg viewBox=\"0 0 14 14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8.5 3.5 5 7l3.5 3.5\"/></svg></button>\n        <div class=\"vs-data-table-pages\"><i class=\"vs-data-table-pill\" aria-hidden=\"true\"></i></div>\n        <button type=\"button\" data-go=\"1\" aria-label=\"Next page\"><svg viewBox=\"0 0 14 14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5.5 3.5 9 7l-3.5 3.5\"/></svg></button>\n      </nav><span class=\"vs-data-table-sr\" aria-live=\"polite\"></span>`;\n    root.appendChild(foot);\n    const rangeEl = foot.querySelector('.vs-data-table-range'), live = foot.querySelector('.vs-data-table-sr');\n    const selectEl = foot.querySelector('select'), pagesEl = foot.querySelector('.vs-data-table-pages'), pillEl = pagesEl.querySelector('.vs-data-table-pill');\n    const prevB = foot.querySelector('[data-go=\"-1\"]'), nextB = foot.querySelector('[data-go=\"1\"]');\n    selectEl.value = String(size);\n    const pillX = spring(0, 420, .68), pillW = spring(32, 420, .68);\n\n    // freeze column widths (measured with every row present) so sorting and paging never shift columns\n    const freeze = () => {\n      table.classList.remove('is-fixed'); [...table.querySelectorAll('colgroup')].forEach((c) => c.remove());\n      const shown = [...tbody.rows]; tbody.replaceChildren(...all);\n      table.style.width = 'max-content'; table.style.minWidth = '0';\n      const W = table.offsetWidth, ws = [...headRow.cells].map((c) => c.getBoundingClientRect().width);\n      table.style.width = ''; table.style.minWidth = `max(${Math.ceil(W)}px, var(--vs-data-table-min))`;\n      tbody.replaceChildren(...shown);\n      // the checkbox column keeps its pixel width; the rest share the table by their natural widths\n      const fixedW = selectable ? ws[0] : 0;\n      const cg = document.createElement('colgroup');\n      ws.forEach((w, i) => { const col = document.createElement('col'); col.style.width = selectable && i === 0 ? w + 'px' : (w / (W - fixedW) * 100).toFixed(3) + '%'; cg.appendChild(col); });\n      table.prepend(cg); table.classList.add('is-fixed');\n      // left offset for the second sticky column\n      if (selectable) { const w0 = headRow.cells[0].getBoundingClientRect().width; [...table.querySelectorAll('.vs-data-table-stick')].forEach((c) => { if (c.cellIndex === 1) c.style.setProperty('--_l', w0 + 'px'); }); }\n    };\n\n    // ---------- motion loop ----------\n    const rows = new Map(); // tr -> { y, x, o, d, z }\n    let raf = 0, last = 0, visible = true;\n    const kick = () => { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n    const paintRow = (tr, a) => {\n      tr.style.transform = `translate3d(${a.x.x.toFixed(2)}px,${a.y.x.toFixed(2)}px,0)`;\n      tr.style.opacity = Math.max(0, Math.min(1, a.o.x)).toFixed(3);\n    };\n    const settleRow = (tr) => { tr.style.transform = ''; tr.style.opacity = ''; tr.style.zIndex = ''; tr.classList.remove('is-moving'); rows.delete(tr); };\n    function tick(now) {\n      raf = 0;\n      const dt = Math.min(.04, (now - last) / 1000 || .016); last = now;\n      let busy = false;\n      const snap = reduce.matches || !visible;\n      for (const [tr, a] of rows) {\n        if (snap) { settleRow(tr); continue; }\n        if (now < a.d) { busy = true; paintRow(tr, a); continue; }\n        const m = step(a.y, dt) | step(a.x, dt) | step(a.o, dt, .002);\n        if (m) { busy = true; paintRow(tr, a); } else settleRow(tr);\n      }\n      if (snap) { bar.x = bar.t; bar.v = 0; } else if (step(bar, dt)) busy = true;\n      hl.style.transform = `translate3d(0,${bar.x.toFixed(2)}px,0)`;\n      const clip = Math.max(0, scroll.scrollTop + table.tHead.offsetHeight - bar.x);\n      hl.style.clipPath = clip > 0 ? `inset(${clip.toFixed(1)}px -20px -20px -20px)` : '';\n      for (const c of chevs) { if (!c) continue; if (snap) { c.s.x = c.s.t; c.s.v = 0; } else if (step(c.s, dt, .1)) busy = true; else if (c.s.t >= 360) { c.s.t %= 360; c.s.x = c.s.t; } c.el.style.transform = `rotate(${c.s.x.toFixed(2)}deg)`; }\n      if (snap) { pillX.x = pillX.t; pillW.x = pillW.t; pillX.v = pillW.v = 0; } else { if (step(pillX, dt)) busy = true; if (step(pillW, dt)) busy = true; }\n      pillEl.style.transform = `translate3d(${pillX.x.toFixed(2)}px,0,0)`; pillEl.style.width = Math.max(8, pillW.x).toFixed(2) + 'px';\n      if (busy) raf = requestAnimationFrame(tick);\n    }\n\n    // ---------- render ----------\n    const ordered = () => {\n      if (sortCol < 0 || !sortDir) return all.slice();\n      const type = ths[sortCol].dataset.type || 'text', ci = sortCol + off;\n      const key = (tr) => { const c = tr.cells[ci]; const v = c ? (c.dataset.sort ?? c.textContent.trim()) : ''; return type === 'number' ? parseFloat(String(v).replace(/[^0-9.\\-]/g, '')) || 0 : v; };\n      const m = sortDir === 'asc' ? 1 : -1;\n      return all.map((tr, i) => ({ tr, k: key(tr), i })).sort((a, b) => {\n        const r = type === 'number' ? a.k - b.k : type === 'date' ? (a.k < b.k ? -1 : a.k > b.k ? 1 : 0) : collator.compare(a.k, b.k);\n        return r * m || a.i - b.i; // stable\n      }).map((o) => o.tr);\n    };\n    const pages = () => Math.max(1, Math.ceil(all.length / size));\n    let shown = [], prevList = all.slice(), prevPageNo = 0;\n    let ghost = null;\n    const killGhost = () => { if (ghost) { ghost.remove(); ghost = null; } };\n\n    function render(mode, dir = 1) {\n      const list = ordered(); page = Math.min(page, pages() - 1);\n      const next = list.slice(page * size, page * size + size);\n      const rowH = (shown[0] || next[0] || {}).offsetHeight || 46;\n      // FIRST: where each visible row is drawn right now (layout top + current offset)\n      const before = new Map(shown.map((tr) => { const a = rows.get(tr); return [tr, tr.offsetTop + (a ? a.y.x : 0)]; }));\n      const beforeIdx = new Map(prevList.map((tr, i) => [tr, i]));   // index in the previous ordering\n      const beforeIdxNew = new Map(list.map((tr, i) => [tr, i]));   // index in the new ordering\n      const prevPage = prevPageNo;\n      const nextSet = new Set(next);\n      const leaving = shown.filter((tr) => !nextSet.has(tr));\n      if ((mode === 'page' || mode === 'sort') && leaving.length && !reduce.matches && visible) {\n        // outgoing rows leave from a ghost copy of the table laid over the real one, so the old and\n        // new rows can overlap: a page change cross-slides, a sort sends leavers toward their new page\n        killGhost();\n        ghost = table.cloneNode(false); ghost.classList.add('vs-data-table-ghost'); ghost.removeAttribute('id');\n        ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true; ghost.style.width = table.offsetWidth + 'px'; ghost.style.minWidth = '0';\n        const cg = table.querySelector('colgroup'); if (cg) ghost.appendChild(cg.cloneNode(true));\n        ghost.appendChild(table.tHead.cloneNode(true));\n        const gb = document.createElement('tbody'), outs = [];\n        shown.forEach((tr, i) => {\n          const c = tr.cloneNode(true); c.classList.remove('is-hot', 'is-moving'); c.removeAttribute('tabindex');\n          const a = rows.get(tr); c.style.transform = a ? `translate3d(${a.x.x}px,${a.y.x}px,0)` : ''; c.style.opacity = a ? Math.max(0, Math.min(1, a.o.x)) : '';\n          c.querySelectorAll('input').forEach((x) => (x.tabIndex = -1));\n          if (nextSet.has(tr)) c.style.visibility = 'hidden'; else outs.push([c, tr, i]);\n          gb.appendChild(c);\n        });\n        ghost.appendChild(gb); ghost.style.top = table.offsetTop + 'px';\n        scroll.appendChild(ghost);\n        const g = ghost; let done = 0;\n        outs.forEach(([c, tr, i], k) => {\n          let to;\n          if (mode === 'page') to = `translate3d(${-dir * 28}px,0,0)`;\n          else { const down = (beforeIdxNew.get(tr) ?? 0) >= page * size + size; to = `translate3d(0,${(down ? 1 : -1) * rowH * 1.1}px,0)`; }\n          const an = c.animate([{ opacity: c.style.opacity || 1, transform: c.style.transform || 'none' }, { opacity: 0, transform: to }],\n            { duration: mode === 'page' ? 130 : 190, delay: (mode === 'page' ? i * 5 : k * 8), easing: mode === 'page' ? 'cubic-bezier(.3,0,.6,1)' : 'cubic-bezier(.4,0,.7,.2)', fill: 'forwards' });\n          an.onfinish = () => { if (++done === outs.length && ghost === g) killGhost(); };\n        });\n      }\n      shown.forEach((tr) => { if (!nextSet.has(tr)) settleRow(tr); });\n      tbody.replaceChildren(...next);\n      shown = next; prevList = list; prevPageNo = page;\n      // LAST + INVERT + PLAY\n      if (!reduce.matches && visible) {\n        const now = performance.now();\n        next.forEach((tr, i) => {\n          let a = rows.get(tr);\n          if (!a) { a = { y: spring(0, 250, .8), x: spring(0, 230, .82), o: spring(1, 260, 1), d: 0 }; }\n          const top = tr.offsetTop;\n          if (mode === 'sort' && before.has(tr)) {\n            const dy = before.get(tr) - top;\n            if (Math.abs(dy) < .5 && !rows.has(tr)) return;\n            a.y.x = dy; a.y.t = 0;\n            const far = Math.min(8, Math.abs(dy) / rowH);\n            a.d = now + far * 11; // far movers lag a beat\n            tr.style.zIndex = String(1 + Math.min(7, Math.round(far))); // …and ride above the rows they pass\n          } else if (mode === 'sort') {\n            // arriving from another page: glide in from the side it came from\n            const from = beforeIdx.get(tr) ?? 0, above = from < prevPage * size;\n            a.y.x = (above ? -1 : 1) * rowH * 1.4; a.y.v = 0; a.o.x = 0; a.o.v = 0;\n            a.y.k = 320; a.y.c = 2 * Math.sqrt(320) * .85;\n            a.d = now + 80 + (above ? next.length - i : i) * 11; tr.style.zIndex = '1';\n          } else if (mode === 'page') {\n            a.x.x = dir * 40; a.x.v = 0; a.y.x = 0; a.y.v = 0; a.o.x = 0; a.o.v = 0; a.d = now + 100 + i * 15;\n          } else if (mode === 'size' && !before.has(tr)) {\n            a.y.x = 10; a.o.x = 0; a.d = now + (i - before.size) * 14;\n          } else return;\n          tr.classList.add('is-moving');\n          rows.set(tr, a); paintRow(tr, a);\n        });\n        kick();\n      }\n      rovingFix();\n      more();\n      updateFoot();\n      syncHead();\n      if (hot && !next.includes(hot)) { hot.classList.remove('is-hot'); hot = null; }\n      requestAnimationFrame(() => (lastPY != null ? setHot(rowAt(lastPY)) : placeBar()));\n    }\n\n    // ---------- footer ----------\n    function updateFoot() {\n      const n = all.length, a = n ? page * size + 1 : 0, b = Math.min(n, (page + 1) * size), P = pages();\n      rangeEl.innerHTML = `<b>${a}–${b}</b> of <b>${n}</b>` + (selectable ? `<span class=\"vs-data-table-selc\"${sel.size ? '' : ' hidden'}>${sel.size} selected</span>` : '');\n      prevB.disabled = page === 0; nextB.disabled = page >= P - 1;\n      // page buttons, windowed: 1 … p-1 p p+1 … P\n      const want = [];\n      for (let i = 0; i < P; i++) if (P <= 7 || i === 0 || i === P - 1 || Math.abs(i - page) <= 1) want.push(i); else if (want[want.length - 1] !== -1) want.push(-1);\n      const key = want.join(',');\n      if (key === pagerKey) { pagesEl.querySelectorAll('button').forEach((b) => (+b.dataset.page === page ? b.setAttribute('aria-current', 'page') : b.removeAttribute('aria-current'))); placePill(); return; }\n      const hadFocus = pagesEl.contains(document.activeElement);\n      pagerKey = key;\n      [...pagesEl.children].forEach((c) => { if (c !== pillEl) c.remove(); });\n      want.forEach((i) => {\n        if (i === -1) { const g = document.createElement('span'); g.className = 'vs-data-table-gap'; g.textContent = '…'; g.setAttribute('aria-hidden', 'true'); pagesEl.appendChild(g); return; }\n        const b = document.createElement('button'); b.type = 'button'; b.textContent = i + 1; b.dataset.page = i;\n        b.setAttribute('aria-label', `Page ${i + 1}`); if (i === page) b.setAttribute('aria-current', 'page');\n        pagesEl.appendChild(b);\n      });\n      if (hadFocus) { const b = pagesEl.querySelector('[aria-current]'); if (b) b.focus(); }\n      placePill();\n    }\n    let pillReady = false, pagerKey = '';\n    function placePill() {\n      const b = pagesEl.querySelector('[aria-current]'); if (!b) return;\n      pillX.t = b.offsetLeft; pillW.t = b.offsetWidth;\n      if (!pillReady || reduce.matches) { pillX.x = pillX.t; pillW.x = pillW.t; pillReady = true; }\n      kick();\n    }\n    pagesEl.addEventListener('click', (e) => { const b = e.target.closest('button[data-page]'); if (b) goPage(+b.dataset.page); });\n    prevB.addEventListener('click', () => goPage(page - 1));\n    nextB.addEventListener('click', () => goPage(page + 1));\n    selectEl.addEventListener('change', () => { const first = page * size; size = +selectEl.value; page = Math.floor(first / size); render('size'); announce(`${size} rows per page`); });\n\n    function goPage(n) {\n      n = Math.max(0, Math.min(pages() - 1, n)); if (n === page) return;\n      const dir = n > page ? 1 : -1; page = n; scroll.scrollTop = 0;\n      render('page', dir);\n      announce(`Page ${page + 1} of ${pages()}`);\n      root.dispatchEvent(new CustomEvent('pagechange', { bubbles: true, detail: { page: page + 1, pages: pages() } }));\n    }\n    function sort(i, dir) {\n      if (!ths[i] || ths[i].hasAttribute('data-nosort')) return;\n      sortCol = dir ? i : -1; sortDir = dir || null;\n      ths.forEach((th, j) => {\n        if (th.hasAttribute('data-nosort')) return;\n        const on = j === sortCol;\n        th.setAttribute('aria-sort', on ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none');\n        const c = chevs[j]; if (!c || !on) return;\n        // asc points up (0deg mod 360), desc down (180): always swing on clockwise to the next match\n        const want = sortDir === 'desc' ? 180 : 0; let t = c.s.t;\n        while (((t % 360) + 360) % 360 !== want) t += 180;\n        c.s.t = t;\n      });\n      page = 0;\n      render('sort');\n      announce(sortCol < 0 ? 'Sorting cleared' : `Sorted by ${ths[i].textContent.trim()}, ${sortDir === 'asc' ? 'ascending' : 'descending'}`);\n      root.dispatchEvent(new CustomEvent('sortchange', { bubbles: true, detail: { column: sortCol, dir: sortDir } }));\n    }\n    const announce = (t) => { live.textContent = ''; setTimeout(() => (live.textContent = t), 30); };\n\n    // ---------- highlight bar ----------\n    function placeBar() {\n      if (!hot || !shown.includes(hot)) { hl.classList.remove('is-on', 'is-ring'); barShown = false; return; }\n      const y = table.offsetTop + hot.offsetTop;\n      hl.style.width = table.offsetWidth + 'px'; hl.style.height = hot.offsetHeight + 'px';\n      bar.t = y;\n      if (!barShown || reduce.matches) { bar.x = y; bar.v = 0; }\n      barShown = true; hl.classList.add('is-on'); kick();\n    }\n    const setHot = (tr, ring) => {\n      if (hot !== tr) { if (hot) hot.classList.remove('is-hot'); hot = tr; if (tr) tr.classList.add('is-hot'); }\n      hl.classList.toggle('is-ring', !!ring && !!tr);\n      placeBar();\n    };\n    const rowAt = (clientY) => {\n      const y = clientY - table.getBoundingClientRect().top;\n      return shown.find((tr) => y >= tr.offsetTop && y < tr.offsetTop + tr.offsetHeight) || null;\n    };\n    let lastPY = null;\n    tbody.addEventListener('pointermove', (e) => { lastPY = e.clientY; if (e.pointerType !== 'touch') setHot(rowAt(e.clientY)); });\n    tbody.addEventListener('pointerdown', (e) => { lastPY = e.clientY; setHot(rowAt(e.clientY)); });\n    tbody.addEventListener('pointerleave', (e) => { lastPY = null; if (e.pointerType !== 'touch' && !tbody.contains(document.activeElement)) setHot(null); });\n    scroll.addEventListener('scroll', () => {\n      root.classList.toggle('is-scrolled', scroll.scrollTop > 1);\n      root.classList.toggle('is-xs', scroll.scrollLeft > 1);\n      more();\n      if (lastPY != null) setHot(rowAt(lastPY)); else kick();\n    }, { passive: true });\n\n    const more = () => scroll.classList.toggle('is-more', scroll.scrollTop + scroll.clientHeight < scroll.scrollHeight - 2);\n\n    // ---------- selection ----------\n    function syncHead() {\n      if (!headCb) return;\n      const n = shown.filter((tr) => sel.has(tr)).length;\n      headCb.checked = n > 0 && n === shown.length; headCb.indeterminate = n > 0 && n < shown.length;\n    }\n    const setSel = (tr, on) => {\n      if (on) sel.add(tr); else sel.delete(tr);\n      tr.classList.toggle('is-sel', on); tr.setAttribute('aria-selected', on);\n      const cb = tr.querySelector('.vs-data-table-check'); if (cb) cb.checked = on;\n    };\n    const selChanged = () => { syncHead(); updateFoot(); root.dispatchEvent(new CustomEvent('selectionchange', { bubbles: true, detail: { rows: all.filter((t) => sel.has(t)) } })); };\n    if (selectable) {\n      all.forEach((tr) => tr.setAttribute('aria-selected', 'false'));\n      tbody.addEventListener('change', (e) => { const cb = e.target.closest('.vs-data-table-check'); if (!cb) return; setSel(cb.closest('tr'), cb.checked); selChanged(); });\n      headCb.addEventListener('change', () => { const on = headCb.checked; shown.forEach((tr) => setSel(tr, on)); selChanged(); });\n    }\n\n    // ---------- keyboard: roving row focus ----------\n    let focusRow = null;\n    function rovingFix() {\n      if (!focusRow || !shown.includes(focusRow)) focusRow = shown[0] || null;\n      all.forEach((tr) => (tr.tabIndex = tr === focusRow ? 0 : -1));\n    }\n    const focusTo = (tr) => { if (!tr) return; focusRow = tr; rovingFix(); tr.focus({ preventScroll: true }); const t = tr.offsetTop, h = tr.offsetHeight, head = table.tHead.offsetHeight; if (t < scroll.scrollTop + head) scroll.scrollTop = t - head; else if (t + h > scroll.scrollTop + scroll.clientHeight) scroll.scrollTop = t + h - scroll.clientHeight; setHot(tr, true); };\n    tbody.addEventListener('keydown', (e) => {\n      const tr = e.target.closest('tr'); if (!tr || e.target !== tr) return;\n      const i = shown.indexOf(tr);\n      if (e.key === 'ArrowDown') focusTo(shown[Math.min(shown.length - 1, i + 1)]);\n      else if (e.key === 'ArrowUp') focusTo(shown[Math.max(0, i - 1)]);\n      else if (e.key === 'Home') focusTo(shown[0]);\n      else if (e.key === 'End') focusTo(shown[shown.length - 1]);\n      else if (e.key === 'PageDown' || e.key === 'PageUp') { goPage(page + (e.key === 'PageDown' ? 1 : -1)); focusTo(shown[Math.min(i, shown.length - 1)]); }\n      else if ((e.key === ' ' || e.key === 'Enter') && selectable) { setSel(tr, !sel.has(tr)); selChanged(); }\n      else return;\n      e.preventDefault();\n    });\n    tbody.addEventListener('focusin', (e) => { const tr = e.target.closest('tr'); if (tr && e.target === tr) { focusRow = tr; if (tr.matches(':focus-visible')) setHot(tr, true); } });\n    tbody.addEventListener('focusout', (e) => { if (!tbody.contains(e.relatedTarget)) { hl.classList.remove('is-ring'); if (lastPY == null) setHot(null); } });\n\n    // ---------- layout observers ----------\n    const relayout = () => {\n      freeze();\n      root.classList.toggle('is-x', table.offsetWidth > scroll.clientWidth + 1);\n      placeBar(); placePill(); more();\n    };\n    new ResizeObserver(relayout).observe(scroll);\n    if (document.fonts) document.fonts.ready.then(relayout);\n    new IntersectionObserver((es) => { visible = es[0].isIntersecting; if (!visible) kick(); }).observe(root);\n\n    root.vsDataTable = {\n      sort: (i, dir) => sort(i, dir === undefined ? 'asc' : dir),\n      page: (n) => goPage(n - 1),\n      pageSize: (n) => { selectEl.value = String(n); selectEl.dispatchEvent(new Event('change')); },\n      selected: () => all.filter((t) => sel.has(t)),\n      get state() { return { column: sortCol, dir: sortDir, page: page + 1, pages: pages(), size }; },\n    };\n    render('init');\n    relayout();\n  }\n\n  const boot = () => document.querySelectorAll('.vs-data-table').forEach(init);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n  window.vsDataTableInit = boot;\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Data display",
    "tags": [
      "data table",
      "table",
      "sortable",
      "pagination",
      "flip",
      "spring",
      "sticky header",
      "row selection",
      "keyboard"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Mulish:wght@400;600&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#2b59ff",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/data-table/index.html",
    "thumbnail": "https://vividsites.app/api/component/data-table/thumb.jpg",
    "video": "https://vividsites.app/api/component/data-table/thumb.mp4",
    "page": "https://vividsites.app/c/data-table"
  }
}