Per-cell custom borders (KPI)

Editable KPI scorecard. spreadsheetLayout paints HOT-style per-edge custom borders via an absolute-positioned overlay (no border-collapse conflicts). Edit any quarter or target - the borders re-derive: green double = beat target, blue solid = hit, amber dotted = near miss, red dashed = bad miss; row champion gets a colored full frame.

A live, editable Svelte 5 data grid example. Open the interactive demo or read the documentation.

What this example shows

Opens like Excel: row gutter 1..1000, column letters A..Z, every cell editable. Rows 1..16 hold a KPI scorecard whose borders encode performance bands - everything below is empty, like a fresh sheet.

`spreadsheetLayout` paints HOT-style per-edge borders via an absolute-positioned overlay div inside each cell (no border- collapse fights). Edit any Plan or Actual cell - subtotals and borders recompute live.

Source code (169-cell-borders.svelte)

<script lang="ts">
  /**
   * 169. Per-cell custom borders - KPI region on a real spreadsheet
   * ----------------------------------------------------------------
   * Opens like Excel: row gutter 1..1000, column letters A..Z, every
   * cell editable. Rows 1..16 hold a KPI scorecard whose borders
   * encode performance bands - everything below is empty, like a
   * fresh sheet.
   *
   * `spreadsheetLayout` paints HOT-style per-edge borders via an
   * absolute-positioned overlay div inside each cell (no border-
   * collapse fights). Edit any Plan or Actual cell - subtotals and
   * borders recompute live.
   */
  import { tick } from 'svelte'
  import {
    SvGrid,
    tableFeatures,
    renderSnippet,
    spreadsheetLayout,
    type ColumnDef,
    type CellBorderSpec,
    type SvGridApi,
  } from '@svgrid/grid'

  let gridWrapper: HTMLDivElement | null = null
  function focusGrid() {
    const tableEl = gridWrapper?.querySelector<HTMLElement>('table.sv-grid-table')
    tableEl?.focus({ preventScroll: true })
  }
  function maybeRefocusGrid(e: MouseEvent) {
    const target = e.target as HTMLElement | null
    if (!target) return
    if (target.closest('input, button, select, textarea, [tabindex]')) return
    tick().then(focusGrid)
  }

  const COL_LETTERS = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'] as const
  type ColKey = typeof COL_LETTERS[number]
  type Row = { rn: number } & Record<ColKey, string | number>

  const TOTAL_ROWS = 1000

  type Section = 'engineering' | 'sales' | 'marketing'
  const SECTION_COLORS: Record<Section, string> = {
    engineering: '#8b5cf6',
    sales:       '#06b6d4',
    marketing:   '#ec4899',
  }
  const PERF_BEAT  = '#10b981'
  const PERF_HIT   = '#3b82f6'
  const PERF_NEAR  = '#f59e0b'
  const PERF_MISS  = '#ef4444'
  const CHAMPION   = '#eab308'
  const TOTAL_FG   = '#0f172a'

  function emptyRow(rn: number): Row {
    const r: Record<string, unknown> = { rn }
    for (const c of COL_LETTERS) r[c] = ''
    return r as Row
  }

  // Build the sheet with the KPI region in rows 1..16
  // Columns: A=team, B=Q1, C=Q2, D=Q3, E=Q4, F=Target
  function buildSeed(): Row[] {
    const out = Array.from({ length: TOTAL_ROWS }, (_, i) => emptyRow(i + 1))
    const set = (rowOneBased: number, col: ColKey, v: string | number) => {
      out[rowOneBased - 1]![col] = v
    }
    // Row 1 - title
    set(1, 'A', 'QUARTERLY KPI SCORECARD')
    // Row 2 - headers
    set(2, 'A', 'Team'); set(2, 'B', 'Q1'); set(2, 'C', 'Q2'); set(2, 'D', 'Q3'); set(2, 'E', 'Q4'); set(2, 'F', 'Target/Q')
    // Rows 3..6: Engineering header + 3 teams + subtotal
    set(3, 'A', 'Engineering')
    set(4, 'A', 'Platform team');  set(4, 'B', 1_240); set(4, 'C', 1_380); set(4, 'D', 1_510); set(4, 'E', 1_690); set(4, 'F', 1_500)
    set(5, 'A', 'Data team');      set(5, 'B',   980); set(5, 'C', 1_010); set(5, 'D', 1_140); set(5, 'E', 1_320); set(5, 'F', 1_200)
    set(6, 'A', 'Reliability');    set(6, 'B',   620); set(6, 'C',   590); set(6, 'D',   710); set(6, 'E',   650); set(6, 'F',   720)
    set(7, 'A', 'Engineering total')
    // Rows 8..11: Sales
    set(8, 'A', 'Sales')
    set(9,  'A', 'Enterprise');    set(9,  'B', 2_400); set(9,  'C', 2_650); set(9,  'D', 3_120); set(9,  'E', 3_780); set(9,  'F', 2_900)
    set(10, 'A', 'Mid-market');    set(10, 'B', 1_650); set(10, 'C', 1_580); set(10, 'D', 1_720); set(10, 'E', 1_840); set(10, 'F', 1_800)
    set(11, 'A', 'SMB');           set(11, 'B',   910); set(11, 'C',   780); set(11, 'D',   840); set(11, 'E',   720); set(11, 'F', 1_000)
    set(12, 'A', 'Sales total')
    // Rows 13..16: Marketing
    set(13, 'A', 'Marketing')
    set(14, 'A', 'Brand');         set(14, 'B',   320); set(14, 'C',   350); set(14, 'D',   410); set(14, 'E',   460); set(14, 'F',   400)
    set(15, 'A', 'Demand gen');    set(15, 'B',   540); set(15, 'C',   620); set(15, 'D',   590); set(15, 'E',   810); set(15, 'F',   650)
    set(16, 'A', 'Community');     set(16, 'B',   180); set(16, 'C',   210); set(16, 'D',   165); set(16, 'E',   140); set(16, 'F',   220)
    set(17, 'A', 'Marketing total')
    set(18, 'A', 'Company total')
    return out
  }

  let rows = $state<Row[]>(buildSeed())

  // Row classification (0-based engine indices).
  const ITEM_ROWS = {
    engineering: [3, 4, 5],
    sales:       [8, 9, 10],
    marketing:   [13, 14, 15],
  } as const
  const HEADER_ROWS = { engineering: 2, sales: 7, marketing: 12 }
  const SUBTOTAL_ROWS = { engineering: 6, sales: 11, marketing: 16 }
  const TOTAL_ROW = 17
  const QUARTERS: ColKey[] = ['B', 'C', 'D', 'E']
  const TARGET_COL: ColKey = 'F'

  function recompute() {
    const sums = {
      engineering: { B: 0, C: 0, D: 0, E: 0, F: 0 },
      sales:       { B: 0, C: 0, D: 0, E: 0, F: 0 },
      marketing:   { B: 0, C: 0, D: 0, E: 0, F: 0 },
    } as Record<Section, Record<'B'|'C'|'D'|'E'|'F', number>>
    for (const sec of ['engineering','sales','marketing'] as const) {
      for (const r of ITEM_ROWS[sec]) {
        for (const c of [...QUARTERS, TARGET_COL]) {
          const v = rows[r]![c]
          if (typeof v === 'number') sums[sec][c as 'B'|'C'|'D'|'E'|'F'] += v
        }
      }
      const subRow = rows[SUBTOTAL_ROWS[sec]]!
      for (const c of [...QUARTERS, TARGET_COL]) subRow[c] = sums[sec][c as 'B'|'C'|'D'|'E'|'F']
    }
    const totalRow = rows[TOTAL_ROW]!
    for (const c of [...QUARTERS, TARGET_COL]) {
      totalRow[c] = sums.engineering[c as 'B'|'C'|'D'|'E'|'F']
                  + sums.sales[c as 'B'|'C'|'D'|'E'|'F']
                  + sums.marketing[c as 'B'|'C'|'D'|'E'|'F']
    }
  }
  recompute()

  let api = $state<SvGridApi<typeof features, Row> | null>(null)

  function onCellValueChange(e: { rowIndex: number; columnId: string; newValue: unknown }) {
    const col = e.columnId as ColKey
    if (col === 'A') return // text column, no recompute needed
    const r = rows[e.rowIndex]!
    if (typeof e.newValue === 'string') {
      const n = Number(e.newValue.trim())
      if (Number.isFinite(n) && e.newValue.trim() !== '') (r as any)[col] = n
    }
    recompute()
    rows = [...rows]
  }

  const features = tableFeatures({})

  const columns: ColumnDef<typeof features, Row>[] = [
    { field: 'rn', header: '', width: 56, align: 'center', editable: false,
      cellClass: 'sv-row-gutter',
      cell: (ctx) => renderSnippet(RowNumCell, { row: ctx.row.original }) },
    { field: 'A', header: 'A', width: 220, align: 'left', editorType: 'text',
      cell: (ctx) => renderSnippet(LabelCell, { row: ctx.row.original, value: ctx.row.original.A, rowIdx: ctx.row.index }) },
    ...(['B','C','D','E','F'] as const).map((c) => ({
      field: c,
      header: c,
      width: 110,
      align: 'right' as const,
      editorType: 'number' as const,
      cell: (ctx: { row: { original: Row; index: number } }) => renderSnippet(NumCell, {
        row: ctx.row.original, value: ctx.row.original[c], rowIdx: ctx.row.index,
      }),
    }) as ColumnDef<typeof features, Row>),
    ...(['G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'] as const).map((c) => ({
      field: c,
      header: c,
      width: 90,
      align: 'left' as const,
      editorType: 'text' as const,
    }) as ColumnDef<typeof features, Row>),
  ]
  const columnOrder = columns.map((c) => c.field as string)

  // ---- Border specs ------------------------------------------------
  function classifyRow(r: number): { section: Section | null; isHeader: boolean; isItem: boolean; isSubtotal: boolean; isTotal: boolean } {
    if (r === HEADER_ROWS.engineering) return { section: 'engineering', isHeader: true,  isItem: false, isSubtotal: false, isTotal: false }
    if (r === HEADER_ROWS.sales)       return { section: 'sales',       isHeader: true,  isItem: false, isSubtotal: false, isTotal: false }
    if (r === HEADER_ROWS.marketing)   return { section: 'marketing',   isHeader: true,  isItem: false, isSubtotal: false, isTotal: false }
    if (r === SUBTOTAL_ROWS.engineering) return { section: 'engineering', isHeader: false, isItem: false, isSubtotal: true, isTotal: false }
    if (r === SUBTOTAL_ROWS.sales)       return { section: 'sales',       isHeader: false, isItem: false, isSubtotal: true, isTotal: false }
    if (r === SUBTOTAL_ROWS.marketing)   return { section: 'marketing',   isHeader: false, isItem: false, isSubtotal: true, isTotal: false }
    if (r === TOTAL_ROW)                 return { section: null,          isHeader: false, isItem: false, isSubtotal: false, isTotal: true }
    for (const sec of ['engineering','sales','marketing'] as const) {
      if ((ITEM_ROWS[sec] as readonly number[]).includes(r)) return { section: sec, isHeader: false, isItem: true, isSubtotal: false, isTotal: false }
    }
    return { section: null, isHeader: false, isItem: false, isSubtotal: false, isTotal: false }
  }

  function perfBorder(actual: number, target: number) {
    if (target <= 0) return { width: 1, style: 'solid' as const, color: '#cbd5e1' }
    const ratio = actual / target
    if (ratio >= 1.10) return { width: 3, style: 'double' as const, color: PERF_BEAT }
    if (ratio >= 0.95) return { width: 2, style: 'solid'  as const, color: PERF_HIT  }
    if (ratio >= 0.85) return { width: 2, style: 'dotted' as const, color: PERF_NEAR }
    return                       { width: 2, style: 'dashed' as const, color: PERF_MISS }
  }

  const KPI_COLUMNS = ['A','B','C','D','E','F'] as const
  const borders = $derived.by<CellBorderSpec[]>(() => {
    const out: CellBorderSpec[] = []
    // Title row (row 0): heavy bottom across the KPI region
    for (const col of KPI_COLUMNS) {
      out.push({ rowIndex: 0, columnId: col, bottom: { width: 2, color: TOTAL_FG } })
    }
    // Header row (row 1): bottom border under column titles
    for (const col of KPI_COLUMNS) {
      out.push({ rowIndex: 1, columnId: col, bottom: { width: 2, color: '#94a3b8' } })
    }
    for (let r = 2; r <= 17; r += 1) {
      const cls = classifyRow(r)
      if (cls.isHeader && cls.section) {
        const c = SECTION_COLORS[cls.section]
        for (const col of KPI_COLUMNS) out.push({ rowIndex: r, columnId: col, bottom: { width: 3, color: c } })
      } else if (cls.isItem) {
        const target = typeof rows[r]![TARGET_COL] === 'number' ? rows[r]![TARGET_COL] as number : 0
        const championValue = Math.max(
          ...QUARTERS.map((q) => (typeof rows[r]![q] === 'number' ? rows[r]![q] as number : -Infinity)),
        )
        for (const q of QUARTERS) {
          const v = rows[r]![q]
          if (typeof v !== 'number') continue
          const b = perfBorder(v, target)
          const spec: CellBorderSpec = { rowIndex: r, columnId: q, bottom: b }
          if (v === championValue && target > 0) {
            spec.top    = { width: 3, style: 'solid', color: CHAMPION }
            spec.left   = { width: 3, style: 'solid', color: CHAMPION }
            spec.right  = { width: 3, style: 'solid', color: CHAMPION }
            spec.bottom = { width: 3, style: 'solid', color: CHAMPION }
          }
          out.push(spec)
        }
      } else if (cls.isSubtotal && cls.section) {
        const c = SECTION_COLORS[cls.section]
        for (const col of KPI_COLUMNS) {
          out.push({
            rowIndex: r, columnId: col,
            top:    { width: 1, style: 'solid', color: c },
            bottom: { width: 2, style: 'solid', color: c },
          })
        }
      } else if (cls.isTotal) {
        for (const col of KPI_COLUMNS) {
          out.push({
            rowIndex: r, columnId: col,
            top:    { width: 3, style: 'double', color: TOTAL_FG },
            bottom: { width: 2, style: 'solid',  color: TOTAL_FG },
          })
        }
      }
    }
    return out
  })

  function sectionAccent(s: Section): string { return SECTION_COLORS[s] }
</script>

{#snippet RowNumCell({ row }: { row: Row })}
  <span class="row-num">{row.rn}</span>
{/snippet}

{#snippet LabelCell({ row, value, rowIdx }: { row: Row; value: string | number; rowIdx: number })}
  {@const cls = classifyRow(rowIdx)}
  <span
    class="kpi-label"
    class:is-title={rowIdx === 0}
    class:is-header={cls.isHeader}
    class:is-item={cls.isItem}
    class:is-subtotal={cls.isSubtotal}
    class:is-total={cls.isTotal}
    style:--accent={cls.section ? sectionAccent(cls.section) : '#0f172a'}
  >{value}</span>
{/snippet}

{#snippet NumCell({ row, value, rowIdx }: { row: Row; value: string | number; rowIdx: number })}
  {@const cls = classifyRow(rowIdx)}
  {#if typeof value === 'number' && Number.isFinite(value)}
    <span
      class:cell-subtotal={cls.isSubtotal}
      class:cell-total={cls.isTotal}
    >{Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value)}</span>
  {:else}
    <span>{value === '' ? '' : value}</span>
  {/if}
{/snippet}

<section class="flex flex-col flex-1 min-h-0 gap-3 no-zebra">
  <header>
    <h2 class="text-base font-semibold">Per-cell borders on a real spreadsheet</h2>
    <p class="text-xs mt-1" style="color: var(--sg-muted);">
      Spreadsheet shell: row numbers 1..{TOTAL_ROWS}, columns A..Z. Rows 1..18 hold a KPI
      scorecard. Edit any team's quarter or target value (columns B..F) - subtotals
      recompute and <code>spreadsheetLayout</code> repaints the borders:
      <strong>green double</strong> = beat by 10%+, blue solid = hit, amber dotted = near
      miss, red dashed = bad miss. The best quarter on each row gets a colored full frame.
    </p>
  </header>

  <div class="legend">
    <span class="legend-swatch swatch-beat"></span><span>Beat target +10%</span>
    <span class="legend-swatch swatch-hit"></span><span>Hit target</span>
    <span class="legend-swatch swatch-near"></span><span>Slightly missed</span>
    <span class="legend-swatch swatch-miss"></span><span>Badly missed</span>
    <span class="legend-swatch swatch-champion"></span><span>Best quarter</span>
  </div>

  <!-- svelte-ignore a11y_no_static_element_interactions -->
  <div
    class="kpi-wrap"
    bind:this={gridWrapper}
    onclick={maybeRefocusGrid}
    use:spreadsheetLayout={{ borders, columnOrder }}
  >
    <SvGrid responsive={true}
      data={rows}
      columns={columns}
      features={features}
      sortable={false}
      filterable={false}
      selectionMode="cell"
      showColumnFilters={false}
      showPagination={false}
      showRowSelection={false}
      enableInlineEditing={true}
      enableCellSelection={true}
      enableRowSummaries={false}
      rowHeight={28}
      containerHeight="100%"
      fitColumns={false}
      virtualization={true}
      columnVirtualization={true}
      onApiReady={(next) => { api = next; tick().then(focusGrid) }}
      onCellValueChange={onCellValueChange}
    />
  </div>
</section>

<style>
  .kpi-wrap {
    flex: 1;
    min-height: 0;
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 8px;
    overflow: hidden;
    background: var(--sg-bg, #ffffff);
  }

  /* Row-number gutter: cell is interactive so :hover fires + the
     row-resize strip works, but every visual side-effect of selecting
     it is suppressed (no ring, no fill handle, no range tint). */
  :global(.sv-grid-cell.sv-row-gutter) {
    cursor: default;
  }
  :global(.sv-grid-cell.sv-row-gutter.sv-grid-cell-active),
  :global(.sv-grid-cell.sv-row-gutter[data-selected-range="true"]) {
    box-shadow: none !important;
  }
  :global(.sv-grid-cell.sv-row-gutter .sv-grid-fill-handle) {
    display: none !important;
  }
  /* Hide the column-menu hamburger + sort + filter triggers on the
     row-header column header. */
  :global([data-svgrid-header-col="rn"] .sv-grid-col-menu-btn),
  :global([data-svgrid-header-col="rn"] .sv-grid-col-filter-btn),
  :global([data-svgrid-header-col="rn"] .sv-grid-header-sort) {
    display: none !important;
  }

  /* Spreadsheet UX: no zebra, no row hover - only the row + column
     headers tint on hover. */
  :global(.no-zebra .sv-grid-table tbody tr:nth-child(even) .sv-grid-cell),
  :global(.no-zebra .sv-grid-table tbody tr:nth-child(odd)  .sv-grid-cell),
  :global(.no-zebra .sv-grid-table tbody tr:hover .sv-grid-cell) {
    background: transparent !important;
  }
  :global(.no-zebra .sv-grid-table tbody tr .sv-grid-cell.sv-row-gutter) {
    background: var(--sg-header-bg, #f1f5f9) !important;
  }
  :global(.no-zebra .sv-grid-table tbody tr .sv-grid-cell.sv-row-gutter:hover) {
    background: var(--sg-row-hover-bg, #e2e8f0) !important;
  }
  :global(.no-zebra .sv-grid-column:hover) {
    background: var(--sg-row-hover-bg, #e2e8f0) !important;
  }
  .legend {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px 14px;
    font-size: 11px;
    color: var(--sg-muted, #64748b);
    padding: 6px 10px;
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 6px;
    background: var(--sg-header-bg, #f8fafc);
  }
  .legend-swatch {
    display: inline-block;
    width: 22px;
    height: 0;
    margin-right: 4px;
  }
  .swatch-beat     { border-bottom: 3px double #10b981; }
  .swatch-hit      { border-bottom: 2px solid  #3b82f6; }
  .swatch-near     { border-bottom: 2px dotted #f59e0b; }
  .swatch-miss     { border-bottom: 2px dashed #ef4444; }
  .swatch-champion { height: 10px; width: 14px; border: 2px solid #eab308; margin-right: 4px; }

  :global(.row-num) {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    font-size: 11px;
    font-weight: 600;
    color: var(--sg-muted, #64748b);
  }
  :global(.kpi-label) { color: var(--sg-fg, #0f172a); font-size: 13px; }
  :global(.kpi-label.is-title) {
    font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase;
    font-size: 12px; color: var(--sg-fg, #0f172a);
  }
  :global(.kpi-label.is-header) {
    font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em;
    font-size: 11px; color: var(--accent, #64748b);
  }
  :global(.kpi-label.is-item)     { padding-left: 14px; color: var(--sg-fg, #334155); }
  :global(.kpi-label.is-subtotal) {
    font-weight: 700; padding-left: 14px; color: var(--accent, #0f172a);
  }
  :global(.kpi-label.is-total)    {
    font-weight: 800; font-size: 13.5px; color: var(--sg-fg, #0f172a);
  }
  :global(.cell-subtotal) { font-weight: 700; }
  :global(.cell-total)    { font-weight: 800; }
</style>

View this example on GitHub

More Spreadsheet examples

  • Spreadsheet + Ribbon bar - Excel-style Ribbon UI driving the grid via SvGridApi: cell formatting (bold, color, number format), insert/delete row, sort, live SUM/AVG/COUNT.
  • Spreadsheet + formulas - Real formula engine inside the grid: cell refs (A1), ranges (A1:A10), SUM / AVG / IF / COUNTIF / ROUND, arithmetic, string concat, cycle detection.
  • Cell merging (spreadsheet shell) - A real invoice rendered on an Excel-style shell: A / B / C / D / E column letters across the top, row numbers down the left. Brand band, bill-from / bill-to address blocks, meta block, line items, totals, notes, signatures - all assembled from MergeSpec + CellBorderSpec. Editable Qty / Rate / addresses / notes; totals recompute live.
  • HyperFormula integration - Full HyperFormula engine wired into the grid as a peer-optional dep. Editable spreadsheet with A1-style cell refs, dozens of formulas across math (SUM / SUMIF), lookup (VLOOKUP / INDEX-MATCH), text (CONCAT / UPPER), date (TODAY / DATEDIF), logical (IF nests), financial (PMT / IRR / NPV), statistical (AVERAGE / MAX / RANK).
  • Blank sheet - just type - An empty Excel-style sheet on a plain <SvGrid>: column-letter headers (A..Z), a built-in 1..N row gutter, a name box + formula bar with a browsable function picker, gridlines, range selection and a fill handle. A real HyperFormula engine underneath: type a literal or a formula like =SUM(B2:D2) / =IF(...) and every dependent cell recalculates live. Drag a row or column border to resize; right-click for Cut / Copy / Paste / Clear.