Foundations

Filling

Filling cells from the cells beside them: repeated as they are, or continued where there is one way to continue them.

Markdown

Filling gives cells the values of the cells beside them: a block dragged out by its corner, or told to fill down or across. It is how a value is repeated down a column, and how a series is carried on, without typing each one.

Filling a sheet

Select two stock counts and drag the small square at the selection's corner downward: a constant step is continued, anything else repeated. Select one cell and drag: it is copied. Press Escape while dragging to take it back, Command-D to fill a selection down from its first row, and Command-Z to undo a fill in one step.

Select all
Name
Price
In stock
Status
Released
Featured
1
$8.00
0
Draft
2
Rust Kettle 1
$35.61
161
Draft
Feb 6, 2026
3
Ink Mug 1
$10.26
226
In review
Aug 7, 2026
4
Oatmeal Saucer 1
$37.87
387
In review
Sep 12, 2026
5
Rust Tray 1
$12.52
52
Live
Mar 13, 2026
6
Ink Jug 1
$77.17
117
Draft
Aug 14, 2026
7
Oatmeal Bowl 1
$14.78
278
Draft
Oct 19, 2026
8
Rust Plate 1
$79.43
343
In review
Mar 20, 2026

12 rows ยท 0 edits

Code
"use client";

import { ProductGrid } from "./products.tsx";

export default function Sheet() {
  return <ProductGrid count={12} height={300} />;
}

Best practices

Fill along one axis. A fill runs down or up the rows, or along the columns โ€” never both at once. A corner dragged diagonally fills the way it has gone furthest beyond the block, and down the rows where it has gone as far both ways.

Repeat what is there, in its order. A block of several values fills by repeating them in turn, so that the block and what it filled tile. Filling backwards tiles too: the cell before the block takes the block's last value.

Continue a series only when there is one way to continue it. Numbers a constant step apart go on by that step; so do dates a constant number of days apart, and dates on the same day of months a constant number of months apart. It takes at least two values to say there is a step. One value, a gap among the values, steps that differ, text, a choice โ€” each is repeated, not continued.

Never guess. Where the values could be continued in more than one way, they are repeated. The first of two months running is a month after the last, and thirty-one days after it: which was meant is not said, so neither is assumed. Months are not counted from a day that not every month has. A fill that copied when a series was wanted is seen at once and put right; a fill that invented values is found later, if at all.

Continue exactly. After 0.1 and 0.2 comes 0.3, not a number a hair away from it. A series that would run past the last value its kind can hold puts nothing there.

Show what would be filled before it is. While a corner is dragged, outline the cells the fill would reach. Letting go fills them; Escape, before letting go, takes the drag back and changes nothing.

Make a fill one change. However many cells it reaches, a fill is undone by one undo, and is told to the data's owner as one change.

Leave what cannot be changed, and say so. A read-only cell in the way of a fill is left as it was, and so is a cell whose column cannot read the value offered it. Say how many cells were filled and how many were left, and why.

Fill from the first line by a command. "Fill down" gives every row of the selection its first row's values; "fill across", every column its first column's. With one row selected, fill down takes the row above. A command copies and does not continue: only a drag from two cells says there is a series.

Conventions

IntentPointerKeyboardTouch
Fill beside a blockDrag the handle at its end cornerโ€”โ€”
Fill downMenuCommand-DMenu
Fill acrossMenuCommand-RMenu
Take back a fill's dragโ€”Escapeโ€”

Command is โŒ˜ on Apple platforms and Ctrl elsewhere.

See also Cell selection and Undo and redo.

Behaviour

Where a fill goes

A corner dragged below the block fills the rows down to it, as wide as the block
expect(fillTarget(block, { row: 6, column: 3 })).toEqual({
  axis: "rows",
  forward: true,
  bounds: { top: 4, left: 2, bottom: 6, right: 4 },
});
Dragged above, or to either side, it fills back to there, or along the columns
expect(fillTarget(block, { row: 0, column: 3 })).toEqual({
  axis: "rows",
  forward: false,
  bounds: { top: 0, left: 2, bottom: 1, right: 4 },
});
expect(fillTarget(block, { row: 3, column: 6 })).toEqual({
  axis: "columns",
  forward: true,
  bounds: { top: 2, left: 5, bottom: 3, right: 6 },
});
expect(fillTarget(block, { row: 2, column: 0 })).toEqual({
  axis: "columns",
  forward: false,
  bounds: { top: 2, left: 0, bottom: 3, right: 1 },
});
A fill runs along one axis: the one the place dragged to is furthest beyond the block in
expect(fillTarget(block, { row: 5, column: 7 })?.axis).toBe("columns");
expect(fillTarget(block, { row: 7, column: 6 })?.axis).toBe("rows");
// As far one way as the other, down the rows.
expect(fillTarget(block, { row: 5, column: 6 })?.axis).toBe("rows");
Within the block there is nothing to fill
expect(fillTarget(block, { row: 3, column: 2 })).toBeNull();
What is filled never takes in a cell of the block, and always reaches the place dragged to along its axis
const anyCell = fc.record({ row: fc.nat(12), column: fc.nat(12) });
fc.assert(
  fc.property(anyCell, (to) => {
    const target = fillTarget(block, to);
    if (target === null) {
      return (
        to.row >= block.top &&
        to.row <= block.bottom &&
        to.column >= block.left &&
        to.column <= block.right
      );
    }
    const { top, left, bottom, right } = target.bounds;
    const apart = bottom < block.top || top > block.bottom || right < block.left || left > block.right;
    const reaches =
      target.axis === "rows"
        ? to.row >= top && to.row <= bottom
        : to.column >= left && to.column <= right;
    return apart && reaches;
  }),
);

Filling down and across

A block of several rows is filled down from its first row; of several columns, across from its first
expect(fillFromFirst(block, "rows")).toEqual({
  source: { top: 2, left: 2, bottom: 2, right: 4 },
  target: { axis: "rows", forward: true, bounds: { top: 3, left: 2, bottom: 3, right: 4 } },
});
expect(fillFromFirst(block, "columns")).toEqual({
  source: { top: 2, left: 2, bottom: 3, right: 2 },
  target: { axis: "columns", forward: true, bounds: { top: 2, left: 3, bottom: 3, right: 4 } },
});
A block of one row is filled from the row above it, and of one column from the column before
const line = { top: 4, left: 1, bottom: 4, right: 1 };
expect(fillFromFirst(line, "rows")).toEqual({
  source: { top: 3, left: 1, bottom: 3, right: 1 },
  target: { axis: "rows", forward: true, bounds: line },
});
expect(fillFromFirst(line, "columns")?.source).toEqual({ top: 4, left: 0, bottom: 4, right: 0 });
The first row has no row above to be filled from, nor the first column one before
expect(fillFromFirst({ top: 0, left: 3, bottom: 0, right: 5 }, "rows")).toBeNull();
expect(fillFromFirst({ top: 2, left: 0, bottom: 5, right: 0 }, "columns")).toBeNull();

Filling a series

A block's lines that hold values, from its start, fill the rest of it: down its rows, or along its one row
// Rows 2 and 3 hold values; 4 to 6 do not.
const tall = { top: 2, left: 1, bottom: 6, right: 3 };
expect(fillSeries(tall, (line) => line < 2)).toEqual({
  source: { top: 2, left: 1, bottom: 3, right: 3 },
  target: { axis: "rows", forward: true, bounds: { top: 4, left: 1, bottom: 6, right: 3 } },
});
const row = { top: 4, left: 0, bottom: 4, right: 5 };
expect(fillSeries(row, (line) => line === 0)).toEqual({
  source: { top: 4, left: 0, bottom: 4, right: 0 },
  target: { axis: "columns", forward: true, bounds: { top: 4, left: 1, bottom: 4, right: 5 } },
});
A block that starts empty, is full to its end, or is one cell, has no series to fill
const column = { top: 0, left: 0, bottom: 3, right: 0 };
expect(fillSeries(column, () => false)).toBeNull();
expect(fillSeries(column, () => true)).toBeNull();
expect(fillSeries({ top: 1, left: 1, bottom: 1, right: 1 }, () => true)).toBeNull();
// Only the lines before the first empty one are the series: what comes after a gap is filled over.
expect(fillSeries(column, (line) => line !== 1)?.source).toEqual({
  top: 0,
  left: 0,
  bottom: 0,
  right: 0,
});

What fills

Values are repeated in their order, after the line and before it, so that the whole tiles
expect(after(["a", "b", "c"], "other", 4)).toEqual(["a", "b", "c", "a"]);
expect(before(["a", "b", "c"], "other", 4)).toEqual(["c", "a", "b", "c"]);
expect(after(["x"], "other")).toEqual(["x", "x", "x"]);
Numbers a constant step apart go on by that step, forwards and back
expect(after([1, 2], "number")).toEqual([3, 4, 5]);
expect(after([10, 7, 4], "number")).toEqual([1, -2, -5]);
expect(before([10, 20], "number")).toEqual([-20, -10, 0]);
Decimal numbers go on exactly: after 0.1 and 0.2 comes 0.3
expect(after([0.1, 0.2], "number")).toEqual([0.3, 0.4, 0.5]);
expect(after([1.25, 1.5], "number", 2)).toEqual([1.75, 2]);
One number, numbers whose steps differ, and numbers with a gap among them are repeated: a fill does not guess
expect(after([5], "number")).toEqual([5, 5, 5]);
expect(after([1, 2, 4], "number")).toEqual([1, 2, 4]);
expect(after([1, null, 3], "number")).toEqual([1, null, 3]);
Numbers are not continued where the values are not numbers to be continued
expect(after([1, 2], "other")).toEqual([1, 2, 1]);
expect(after(["1", "2"], "number")).toEqual(["1", "2", "1"]);
Dates a constant number of days apart go on by as many days, across months and years
expect(after(["2026-12-30", "2026-12-31"], "date")).toEqual(["2027-01-01", "2027-01-02", "2027-01-03"]);
expect(after(["2026-10-01", "2026-10-08"], "date", 2)).toEqual(["2026-10-15", "2026-10-22"]);
expect(before(["2026-03-02", "2026-03-03"], "date", 2)).toEqual(["2026-02-28", "2026-03-01"]);
Three dates on the same day of months a constant step apart go on by months
expect(after(["2026-10-15", "2026-11-15", "2026-12-15"], "date", 2)).toEqual([
  "2027-01-15",
  "2027-02-15",
]);
expect(before(["2026-01-01", "2026-03-01", "2026-05-01"], "date", 2)).toEqual([
  "2025-09-01",
  "2025-11-01",
]);
Two dates a month apart are as many days apart too: which was meant is not said, and they are repeated
expect(after(["2026-10-01", "2026-11-01"], "date")).toEqual(["2026-10-01", "2026-11-01", "2026-10-01"]);
// March, May and July begin sixty-one days apart, and two months apart.
expect(after(["2026-03-01", "2026-05-01", "2026-07-01"], "date", 1)).toEqual(["2026-03-01"]);
Months are not counted from a day that not every month has
expect(after(["2026-01-31", "2026-03-31", "2026-05-31"], "date", 1)).toEqual(["2026-01-31"]);
A series that runs past the last date a date can write has nothing to put there
expect(after(["9999-12-30", "9999-12-31"], "date", 1)).toEqual([undefined]);
A series of numbers agrees with adding the step, however far and whichever way
fc.assert(
  fc.property(
    fc.integer({ min: -1000, max: 1000 }),
    fc.integer({ min: -50, max: 50 }),
    fc.integer({ min: 2, max: 6 }),
    fc.integer({ min: 1, max: 40 }),
    (start, step, length, offset) => {
      const source = Array.from({ length }, (_, index) => start + step * index);
      const next = continuation(source, "number");
      return (
        next(offset) === start + step * (length - 1 + offset) && next(-offset) === start - step * offset
      );
    },
  ),
);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/fill.json
Source

src/primitives/fill.ts

/**
 * Filling cells from the cells beside them: a block dragged out by its corner,
 * or told to fill down or to the side.
 *
 * - A fill runs along one axis: down or up the rows, or along the columns โ€”
 *   whichever the place dragged to lies furthest beyond the block in.
 * - What fills is the block's own cells, repeated in their order, so that a
 *   block filled by itself tiles: backwards as well as forwards.
 * - Numbers and dates continue instead, when there is one way to continue them
 *   and no other: at least two of them, a constant step apart. Anything else โ€”
 *   one value, a gap, steps that differ, dates that are as many days apart as
 *   they are months โ€” is repeated. A fill never guesses.
 */
import { dateOfDay, dayNumber } from "./calendar.ts";
import type { Bounds, Cell } from "./grid-selection.ts";

export interface FillTarget {
  /** The cells to fill: beside the block, and none of its own. */
  readonly bounds: Bounds;
  /** Whether the fill runs through the rows, or through the columns. */
  readonly axis: "rows" | "columns";
  /** Onward from the block's end, or back from its start. */
  readonly forward: boolean;
}

/** The cells a block fills when its corner is dragged to `to`, or `null` while that is within the block. */
export function fillTarget(source: Bounds, to: Cell): FillTarget | null {
  const down = to.row - source.bottom;
  const up = source.top - to.row;
  const right = to.column - source.right;
  const left = source.left - to.column;
  const rows = Math.max(down, up);
  const columns = Math.max(right, left);
  if (rows <= 0 && columns <= 0) return null;
  // As far one way as the other, the rows have it: down is where a fill most often goes.
  if (rows >= columns) {
    return down > 0
      ? { axis: "rows", forward: true, bounds: { ...source, top: source.bottom + 1, bottom: to.row } }
      : { axis: "rows", forward: false, bounds: { ...source, top: to.row, bottom: source.top - 1 } };
  }
  return right > 0
    ? { axis: "columns", forward: true, bounds: { ...source, left: source.right + 1, right: to.column } }
    : { axis: "columns", forward: false, bounds: { ...source, left: to.column, right: source.left - 1 } };
}

/**
 * What "fill down" and "fill across" fill, and from what: the lines of a block
 * after its first, from the first; or a block of one line, from the line before
 * it. `null` where there is no line to fill from.
 */
export function fillFromFirst(
  block: Bounds,
  axis: FillTarget["axis"],
): { readonly source: Bounds; readonly target: FillTarget } | null {
  const [first, last] = axis === "rows" ? (["top", "bottom"] as const) : (["left", "right"] as const);
  const start = block[first];
  if (block[last] > start) {
    return {
      source: { ...block, [last]: start },
      target: { axis, forward: true, bounds: { ...block, [first]: start + 1 } },
    };
  }
  if (start === 0) return null;
  return {
    source: { ...block, [first]: start - 1, [last]: start - 1 },
    target: { axis, forward: true, bounds: block },
  };
}

/**
 * What "fill series" fills, and from what: a block's lines that hold values, from
 * its start, continued through the rest of it โ€” down the rows of a block more
 * than one row tall, else along its columns. `filled(line)` says whether a line,
 * counted from the block's start, holds any value. `null` where the block starts
 * with an empty line, or is filled to its end.
 */
export function fillSeries(
  block: Bounds,
  filled: (line: number) => boolean,
): { readonly source: Bounds; readonly target: FillTarget } | null {
  const axis: FillTarget["axis"] = block.bottom > block.top ? "rows" : "columns";
  const [first, last] = axis === "rows" ? (["top", "bottom"] as const) : (["left", "right"] as const);
  const lines = block[last] - block[first] + 1;
  let given = 0;
  while (given < lines && filled(given)) given += 1;
  if (given === 0 || given === lines) return null;
  const end = block[first] + given - 1;
  return {
    source: { ...block, [last]: end },
    target: { axis, forward: true, bounds: { ...block, [first]: end + 1 } },
  };
}

/** How the values of a line of cells may be continued: as numbers, as dates, or not at all. */
export type FillKind = "number" | "date" | "other";

/**
 * What a place beside a line of values takes. `offset` counts from the line: 1
 * is the place after its last value, -1 the place before its first.
 * `undefined` is nothing to put there: a date beyond the years a date can write.
 */
export type Continuation = (offset: number) => unknown;

/** How many digits a number is written with after its point, or `null` where it is not written with one. */
function decimals(value: number): number | null {
  const text = String(value);
  if (text.includes("e")) return null;
  const point = text.indexOf(".");
  return point === -1 ? 0 : text.length - point - 1;
}

/** The step every value is from the one before, or `null` when there is no one step. */
function stepOf(values: readonly number[]): number | null {
  const step = values[1]! - values[0]!;
  for (let index = 2; index < values.length; index += 1) {
    if (values[index]! - values[index - 1]! !== step) return null;
  }
  return step;
}

function numbers(source: readonly unknown[]): Continuation | null {
  let places = 0;
  for (const value of source) {
    const own = typeof value === "number" && Number.isFinite(value) ? decimals(value) : null;
    if (own === null) return null;
    places = Math.max(places, own);
  }
  // Counted in whole units of the last decimal place, so that 0.1, 0.2 goes on to 0.3 exactly.
  const scale = 10 ** places;
  const whole = source.map((value) => Math.round((value as number) * scale));
  const step = stepOf(whole);
  if (step === null || !whole.every(Number.isSafeInteger)) return null;
  return (offset) => {
    const next = (offset > 0 ? whole.at(-1)! : whole[0]!) + step * offset;
    return Number.isSafeInteger(next) ? next / scale : undefined;
  };
}

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;

function dates(source: readonly unknown[]): Continuation | null {
  if (!source.every((value): value is string => typeof value === "string" && ISO_DATE.test(value))) {
    return null;
  }
  const days = stepOf(source.map(dayNumber));
  // The same day of each month โ€” one that every month has โ€” so many months apart.
  const day = source[0]!.slice(8);
  const monthly = Number(day) <= 28 && source.every((date) => date.slice(8) === day);
  const months = monthly
    ? stepOf(source.map((date) => Number(date.slice(0, 4)) * 12 + Number(date.slice(5, 7)) - 1))
    : null;
  const byDays = days !== null && days !== 0;
  const byMonths = months !== null && months !== 0;
  // Two dates a month apart are also so many days apart: which was meant is not said.
  if (byDays === byMonths) return null;
  const from = (offset: number) => (offset > 0 ? source.at(-1)! : source[0]!);
  if (byDays) return (offset) => dateOfDay(dayNumber(from(offset)) + days * offset) ?? undefined;
  return (offset) => {
    const date = from(offset);
    const month = Number(date.slice(0, 4)) * 12 + Number(date.slice(5, 7)) - 1 + months! * offset;
    const year = Math.floor(month / 12);
    if (year < 1 || year > 9999) return undefined;
    return `${String(year).padStart(4, "0")}-${String((month % 12) + 1).padStart(2, "0")}-${day}`;
  };
}

/**
 * What the places beside a line of values take: the series the values make,
 * when they are of a kind that makes one and make exactly one; otherwise the
 * values again, in their order.
 */
export function continuation(source: readonly unknown[], kind: FillKind = "other"): Continuation {
  const count = source.length;
  if (count === 0) return () => undefined;
  const series =
    count < 2 ? null : kind === "number" ? numbers(source) : kind === "date" ? dates(source) : null;
  if (series !== null) return series;
  // Before the line as after it, the values tile.
  return (offset) => source[(((offset > 0 ? offset - 1 : offset) % count) + count) % count];
}