diff --git a/frontend/custom-elements-manifest.config.mjs b/frontend/custom-elements-manifest.config.mjs
index 519cd4f61b..bef8676f2d 100644
--- a/frontend/custom-elements-manifest.config.mjs
+++ b/frontend/custom-elements-manifest.config.mjs
@@ -1,8 +1,8 @@
 export default {
   /** Globs to analyze */
-  globs: ["src/**/*.ts"],
+  globs: ["src/components/**/*.ts", "src/features/**/*.ts"],
   /** Globs to exclude */
-  exclude: ["__generated__", "__mocks__"],
+  exclude: ["src/**/*.stories.ts"],
   /** Directory to output CEM to */
   outdir: "src/__generated__",
   /** Run in dev mode, provides extra logging */
@@ -15,4 +15,33 @@ export default {
   packagejson: false,
   /** Enable special handling for litelement */
   litelement: true,
+  /** Provide custom plugins */
+  plugins: [filterPrivateFields()],
 };
+
+// Filter private fields
+// Based on https://github.com/storybookjs/storybook/issues/15436#issuecomment-1856333227
+function filterPrivateFields() {
+  return {
+    name: "web-components-private-fields-filter",
+    analyzePhase({ ts, node, moduleDoc }) {
+      switch (node.kind) {
+        case ts.SyntaxKind.ClassDeclaration: {
+          const className = node.name.getText();
+          const classDoc = moduleDoc?.declarations?.find(
+            (declaration) => declaration.name === className,
+          );
+
+          if (classDoc?.members) {
+            // Filter both private and static members
+            // TODO May be able to avoid some of this with `#` private member prefix
+            // https://github.com/webrecorder/browsertrix/issues/2563
+            classDoc.members = classDoc.members.filter(
+              (member) => !member.privacy && !member.static,
+            );
+          }
+        }
+      }
+    },
+  };
+}
diff --git a/frontend/package.json b/frontend/package.json
index 05072b9e19..248ca68eaa 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -79,6 +79,7 @@
     "replaywebpage": "^2.2.4",
     "slugify": "^1.6.6",
     "style-loader": "^3.3.0",
+    "tabbable": "^6.2.0",
     "tailwindcss": "^3.4.1",
     "terser-webpack-plugin": "^5.3.10",
     "thread-loader": "^4.0.4",
diff --git a/frontend/src/__mocks__/api/orgs/[id].js b/frontend/src/__mocks__/api/orgs/[id].js
new file mode 100644
index 0000000000..93b86b380d
--- /dev/null
+++ b/frontend/src/__mocks__/api/orgs/[id].js
@@ -0,0 +1,156 @@
+// API v1.15.0
+export default {
+  id: "x_example_org_id_x",
+  name: "Example Org",
+  slug: "example-org",
+  users: {
+    "alice@example.com": {
+      role: 40,
+      name: "Alice",
+      email: "alice@example.com",
+    },
+    "bob@example.com": {
+      role: 20,
+      name: "Bob",
+      email: "bob@example.com",
+    },
+    "carol@example.com": {
+      role: 20,
+      name: "Carol",
+      email: "carol@example.com",
+    },
+    "dave@example.com": {
+      role: 10,
+      name: "Dave",
+      email: "dave@example.com",
+    },
+    "eve@example.com": {
+      role: 10,
+      name: "Eve",
+      email: "eve@example.com",
+    },
+  },
+  created: "2023-11-08T17:19:23Z",
+  default: false,
+  bytesStored: 22143878904,
+  bytesStoredCrawls: 22023942070,
+  bytesStoredUploads: 38872471,
+  bytesStoredProfiles: 81064363,
+  origin: null,
+  storageQuotaReached: false,
+  execMinutesQuotaReached: false,
+  usage: {
+    "2023-11": 473,
+    "2023-12": 1273,
+    "2024-01": 4752,
+    "2024-03": 26,
+    "2024-04": 398,
+    "2024-05": 3030,
+    "2024-06": 2628,
+    "2024-07": 1655,
+    "2024-08": 1289,
+    "2024-10": 308,
+    "2025-04": 5723,
+  },
+  crawlExecSeconds: {
+    "2023-11": 760,
+    "2023-12": 1771,
+    "2024-01": 4939,
+    "2024-03": 14,
+    "2024-04": 253,
+    "2024-05": 2958,
+    "2024-06": 4276,
+    "2024-07": 2066,
+    "2024-08": 2250,
+    "2024-10": 233,
+    "2025-01": 1,
+    "2025-04": 5688,
+  },
+  qaUsage: {
+    "2024-04": 214,
+    "2024-05": 1526,
+    "2024-06": 894,
+    "2024-08": 1188,
+    "2024-10": 314,
+    "2025-01": 166,
+  },
+  qaCrawlExecSeconds: {
+    "2024-04": 174,
+    "2024-05": 1484,
+    "2024-06": 1914,
+    "2024-08": 3131,
+    "2024-10": 943,
+    "2025-01": 366,
+  },
+  monthlyExecSeconds: { "2023-11": 760, "2023-12": 1771, "2024-01": 3000 },
+  extraExecSeconds: {},
+  giftedExecSeconds: {},
+  extraExecSecondsAvailable: 0,
+  giftedExecSecondsAvailable: 0,
+  quotas: {
+    storageQuota: 100000000000,
+    maxExecMinutesPerMonth: 0,
+    maxConcurrentCrawls: 10,
+    maxPagesPerCrawl: 0,
+    extraExecMinutes: 0,
+    giftedExecMinutes: 0,
+  },
+  quotaUpdates: [
+    {
+      modified: "2024-01-18T07:16:22.534000Z",
+      update: {
+        storageQuota: 1000000000000,
+        maxExecMinutesPerMonth: 0,
+        maxConcurrentCrawls: 10,
+        maxPagesPerCrawl: 0,
+        extraExecMinutes: 0,
+        giftedExecMinutes: 0,
+      },
+    },
+    {
+      modified: "2024-07-17T15:36:45Z",
+      update: {
+        storageQuota: 10000000000,
+        maxExecMinutesPerMonth: 0,
+        maxConcurrentCrawls: 10,
+        maxPagesPerCrawl: 0,
+        extraExecMinutes: 0,
+        giftedExecMinutes: 0,
+      },
+    },
+    {
+      modified: "2024-07-17T15:39:26Z",
+      update: {
+        storageQuota: 100000000000,
+        maxExecMinutesPerMonth: 0,
+        maxConcurrentCrawls: 10,
+        maxPagesPerCrawl: 0,
+        extraExecMinutes: 0,
+        giftedExecMinutes: 0,
+      },
+    },
+  ],
+  webhookUrls: {
+    crawlStarted: null,
+    crawlFinished: null,
+    crawlDeleted: null,
+    qaAnalysisStarted: null,
+    qaAnalysisFinished: null,
+    crawlReviewed: null,
+    uploadFinished: null,
+    uploadDeleted: null,
+    addedToCollection: null,
+    removedFromCollection: null,
+    collectionDeleted: null,
+  },
+  readOnly: false,
+  readOnlyReason: "",
+  subscription: null,
+  allowSharedProxies: false,
+  allowedProxies: ["nz-proxy-1"],
+  crawlingDefaults: null,
+  lastCrawlFinished: "2025-04-02T23:00:50Z",
+  enablePublicProfile: false,
+  publicDescription: "This is an example org.",
+  publicUrl: "https://example.com",
+};
diff --git a/frontend/src/components/ui/data-grid/cellDirective.ts b/frontend/src/components/ui/data-grid/cellDirective.ts
new file mode 100644
index 0000000000..615b1e8ee5
--- /dev/null
+++ b/frontend/src/components/ui/data-grid/cellDirective.ts
@@ -0,0 +1,29 @@
+import { Directive, type PartInfo } from "lit/directive.js";
+
+import type { DataGridCell } from "./data-grid-cell";
+import type { GridColumn } from "./types";
+
+/**
+ * Directive for replacing `renderCell` and `renderEditCell`
+ * methods with custom render functions.
+ */
+export class CellDirective extends Directive {
+  private readonly element?: DataGridCell;
+
+  constructor(partInfo: PartInfo & { element?: DataGridCell }) {
+    super(partInfo);
+    this.element = partInfo.element;
+  }
+
+  render(col: GridColumn) {
+    if (!this.element) return;
+
+    if (col.renderCell) {
+      this.element.renderCell = col.renderCell;
+    }
+
+    if (col.renderEditCell) {
+      this.element.renderEditCell = col.renderEditCell;
+    }
+  }
+}
diff --git a/frontend/src/components/ui/data-grid/controllers/focus.ts b/frontend/src/components/ui/data-grid/controllers/focus.ts
new file mode 100644
index 0000000000..78957c7a7d
--- /dev/null
+++ b/frontend/src/components/ui/data-grid/controllers/focus.ts
@@ -0,0 +1,135 @@
+import type { ReactiveController } from "lit";
+import {
+  focusable,
+  isFocusable,
+  isTabbable,
+  tabbable,
+  type FocusableElement,
+} from "tabbable";
+
+import type { DataGridCell } from "../data-grid-cell";
+import type { DataGridRow } from "../data-grid-row";
+
+type Options = {
+  /**
+   * Set focus on first non-input item according to
+   * tabindex, rather than DOM order.
+   */
+  setFocusOnTabbable?: boolean;
+};
+
+/**
+ * Utilities for managing focus in a data grid.
+ */
+export class DataGridFocusController implements ReactiveController {
+  readonly #host: DataGridRow | DataGridCell;
+
+  constructor(
+    host: DataGridRow | DataGridCell,
+    opts: Options = {
+      setFocusOnTabbable: false,
+    },
+  ) {
+    this.#host = host;
+    host.addController(this);
+
+    this.#host.addEventListener(
+      "focus",
+      () => {
+        if (!this.#host.matches(":focus-visible")) {
+          // Only handle focus on keyboard tabbing
+          return;
+        }
+
+        const el = opts.setFocusOnTabbable
+          ? this.firstTabbable
+          : this.firstFocusable;
+
+        if (el) {
+          if (this.isFocusableInput(el)) {
+            this.#host.addEventListener("keydown", this.#onFocusForEl(el), {
+              once: true,
+              capture: true,
+            });
+          } else {
+            el.focus();
+          }
+        }
+      },
+      { passive: true, capture: true },
+    );
+  }
+
+  hostConnected() {}
+  hostDisconnected() {}
+
+  /**
+   * Focusable elements in DOM order. This will include
+   * all focusable elements, including elements with `tabindex="1"`.
+   */
+  public get focusable() {
+    return focusable(this.#host, {
+      getShadowRoot: true,
+    });
+  }
+
+  /**
+   * Focusable elements in `tabindex` order.
+   */
+  public get tabbable() {
+    return tabbable(this.#host, {
+      getShadowRoot: true,
+    });
+  }
+
+  public get firstFocusable(): FocusableElement | undefined {
+    return this.focusable[0];
+  }
+
+  public get firstTabbable(): FocusableElement | undefined {
+    return this.tabbable[0];
+  }
+
+  public isFocusable(el: Element) {
+    return isFocusable(el);
+  }
+
+  public isTabbable(el: Element) {
+    return isTabbable(el);
+  }
+
+  public isFocusableInput(el: Element) {
+    // TODO Handle `<sl-select>`/`<sl-option>`
+    return el.tagName === "INPUT" && this.isFocusable(el);
+  }
+
+  /**
+   * Based on recommendations from
+   * https://www.w3.org/WAI/ARIA/apg/patterns/grid/#keyboardinteraction-settingfocusandnavigatinginsidecells
+   */
+  readonly #onFocusForEl = (el: FocusableElement) => (e: KeyboardEvent) => {
+    const { key } = e;
+
+    switch (key) {
+      case "Tab": {
+        // Prevent entering cell
+        e.preventDefault();
+        break;
+      }
+      case "Enter": {
+        e.preventDefault();
+
+        // Enter cell and focus on input
+        el.focus();
+        break;
+      }
+      default: {
+        if (key.length === 1) {
+          // Enter cell and focus on input
+          el.focus();
+        }
+        break;
+      }
+    }
+  };
+}
diff --git a/frontend/src/components/ui/data-grid/controllers/rows.ts b/frontend/src/components/ui/data-grid/controllers/rows.ts
new file mode 100644
index 0000000000..fdb5ceed0b
--- /dev/null
+++ b/frontend/src/components/ui/data-grid/controllers/rows.ts
@@ -0,0 +1,96 @@
+import type { ReactiveController, ReactiveControllerHost } from "lit";
+import { nanoid } from "nanoid";
+import type { EmptyObject } from "type-fest";
+
+import type { DataGrid } from "../data-grid";
+import type { GridItem, GridRowId, GridRows } from "../types";
+
+import { cached } from "@/utils/weakCache";
+
+/**
+ * Enables removing and adding rows from a grid.
+ *
+ * Implementing this controller isn't necessary if the `.items` property
+ * is specified in `<btrix-data-grid>`. For grids with editable rows
+ * that are slotted into `<btrix-data-grid>`, it may be necessary to
+ * implement this controller on the container component.
+ */
+export class DataGridRowsController implements ReactiveController {
+  readonly #host: ReactiveControllerHost &
+    EventTarget & {
+      items?: GridItem[];
+      rowKey?: DataGrid["rowKey"];
+      defaultItem?: DataGrid["defaultItem"];
+      removeRows?: DataGrid["removeRows"];
+      addRows?: DataGrid["addRows"];
+    };
+
+  #prevItems?: GridItem[];
+
+  public rows: GridRows<GridItem> = new Map<GridRowId, GridItem>();
+
+  constructor(host: ReactiveControllerHost & EventTarget) {
+    this.#host = host;
+    host.addController(this);
+  }
+
+  hostConnected() {
+    if (this.#host.items) {
+      this.setItems(this.#host.items);
+    }
+  }
+  hostDisconnected() {}
+  hostUpdate() {
+    if (this.#host.items) {
+      this.setItems(this.#host.items);
+    }
+  }
+
+  private setRowsFromItems<T extends GridItem = GridItem>(items: T[]) {
+    const rowKey = this.#host.rowKey;
+
+    this.rows = new Map(
+      this.#host.rowKey
+        ? items.map((item) => [
+            item[rowKey as unknown as string] as GridRowId,
+            item,
+          ])
+        : items.map(
+            cached((item) => [nanoid(), item], { cacheConstructor: Map }),
+          ),
+    );
+  }
+
+  public setItems<T extends GridItem = GridItem>(items: T[]) {
+    if (!this.#prevItems || items !== this.#prevItems) {
+      this.setRowsFromItems(items);
+
+      // this.#host.requestUpdate();
+
+      this.#prevItems = items;
+    }
+  }
+
+  public addRows<T extends GridItem = GridItem>(
+    defaultItem: T | EmptyObject = {},
+    count = 1,
+  ) {
+    for (let i = 0; i < count; i++) {
+      const id = nanoid();
+
+      this.rows.set(id, defaultItem);
+    }
+
+    this.#host.requestUpdate();
+  }
+
+  public removeRow(id: GridRowId) {
+    this.rows.delete(id);
+
+    if (this.rows.size === 0 && this.#host.defaultItem) {
+      this.addRows(this.#host.defaultItem);
+    }
+
+    this.#host.requestUpdate();
+  }
+}
diff --git a/frontend/src/components/ui/data-grid/data-grid-cell.ts b/frontend/src/components/ui/data-grid/data-grid-cell.ts
new file mode 100644
index 0000000000..acbe1abeae
--- /dev/null
+++ b/frontend/src/components/ui/data-grid/data-grid-cell.ts
@@ -0,0 +1,215 @@
+import type { SlInput, SlSelect } from "@shoelace-style/shoelace";
+import clsx from "clsx";
+import { html, type TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+
+import { TableCell } from "../table/table-cell";
+
+import type { GridColumn, GridColumnSelectType, GridItem } from "./types";
+import { GridColumnType } from "./types";
+
+import { DataGridFocusController } from "@/components/ui/data-grid/controllers/focus";
+import type { UrlInput } from "@/components/ui/url-input";
+import { tw } from "@/utils/tailwind";
+
+const cellInputStyle = [
+  tw`size-full [--sl-input-background-color-hover:transparent] [--sl-input-background-color:transparent] [--sl-input-border-radius-medium:0] [--sl-input-spacing-medium:var(--sl-spacing-small)] focus:z-10`,
+  // TODO We need to upgrade to Tailwind v4 for inset rings to actually work
+  // tw`focus-within:part-[base]:inset-ring-2`,
+  tw`data-[invalid]:[--sl-input-border-color:transparent] data-[valid]:[--sl-input-border-color:transparent]`,
+  tw`part-[form-control-help-text]:mx-1 part-[form-control-help-text]:mb-1`,
+  tw`part-[base]:h-full part-[form-control-input]:h-full part-[form-control]:h-full part-[input]:h-full`,
+  tw`part-[input]:px-[var(--sl-spacing-x-small)]`,
+];
+
+export type InputElement = SlInput | SlSelect | UrlInput;
+
+export type CellEditEventDetail = {
+  field: GridColumn["field"];
+  value: InputElement["value"];
+  validity: InputElement["validity"];
+  validationMessage: InputElement["validationMessage"];
+};
+
+/**
+ * @fires btrix-input CustomEvent
+ * @fires btrix-change CustomEvent
+ */
+@customElement("btrix-data-grid-cell")
+export class DataGridCell extends TableCell {
+  @property({ type: Object })
+  column?: GridColumn;
+
+  @property({ type: Object })
+  item?: GridItem;
+
+  @property({ type: Boolean })
+  editable = false;
+
+  @property({ type: String, reflect: true, noAccessor: true })
+  role = "gridcell";
+
+  @property({ attribute: false })
+  customRenderCell?: () => TemplateResult;
+
+  @property({ attribute: false })
+  customRenderEditCell?: () => TemplateResult;
+
+  @property({ type: Number, reflect: true })
+  tabindex = 0;
+
+  readonly #focus = new DataGridFocusController(this, {
+    setFocusOnTabbable: true,
+  });
+
+  public checkValidity() {
+    return this.input?.checkValidity();
+  }
+
+  public get validity() {
+    return this.input?.validity;
+  }
+
+  public get validationMessage() {
+    return this.input?.validationMessage;
+  }
+
+  public get input() {
+    if (!this.column) return null;
+
+    return this.shadowRoot!.querySelector<InputElement>(
+      `[name=${this.column.field}]`,
+    );
+  }
+
+  protected createRenderRoot() {
+    const root = super.createRenderRoot();
+    const inputEvents = ["btrix-input", "sl-input"];
+    const changeEvents = ["btrix-change", "sl-change"];
+
+    // Attach to render root so that `e.target` is input
+    inputEvents.forEach((name) => {
+      root.addEventListener(name, this.onInput);
+    });
+
+    changeEvents.forEach((name) => {
+      root.addEventListener(name, this.onChange);
+    });
+
+    return root;
+  }
+
+  render() {
+    if (!this.column || !this.item) return html`<slot></slot>`;
+
+    if (this.editable) {
+      return this.renderEditCell({ item: this.item });
+    }
+
+    return this.renderCell({ item: this.item });
+  }
+
+  renderCell = ({ item }: { item: GridItem }) => {
+    return html`${(this.column && item[this.column.field]) ?? ""}`;
+  };
+
+  renderEditCell = ({ item }: { item: GridItem }) => {
+    const col = this.column;
+
+    if (!col) return html``;
+
+    const value = item[col.field] ?? "";
+
+    switch (col.inputType) {
+      case GridColumnType.Select: {
+        return html`
+          <div class="box-border w-full p-1">
+            <sl-select
+              name=${col.field}
+              value=${value}
+              placeholder=${ifDefined(col.inputPlaceholder)}
+              class="w-full min-w-[5em]"
+              size="small"
+              ?required=${col.required}
+              hoist
+            >
+              ${(col as GridColumnSelectType).selectOptions.map(
+                (opt) => html`
+                  <sl-option value=${opt.value}>
+                    ${opt.label ?? opt.value}
+                  </sl-option>
+                `,
+              )}
+            </sl-select>
+          </div>
+        `;
+      }
+      case GridColumnType.URL:
+        return html`<btrix-url-input
+          name=${col.field}
+          class=${clsx(cellInputStyle)}
+          value=${value}
+          placeholder=${ifDefined(col.inputPlaceholder)}
+          ?required=${col.required}
+          hideHelpText
+        >
+        </btrix-url-input>`;
+      default:
+        break;
+    }
+
+    return html`
+      <sl-input
+        name=${col.field}
+        class=${clsx(cellInputStyle)}
+        type=${col.inputType === GridColumnType.Number ? "number" : "text"}
+        value=${value}
+        placeholder=${ifDefined(col.inputPlaceholder)}
+        ?required=${col.required}
+      ></sl-input>
+    `;
+  };
+
+  private readonly onInput = (e: Event) => {
+    if (!this.column) return;
+
+    e.stopPropagation();
+
+    const input = e.target as InputElement;
+
+    this.dispatchEvent(
+      new CustomEvent<CellEditEventDetail>("btrix-input", {
+        detail: {
+          field: this.column.field,
+          value: input.value,
+          validity: input.validity,
+          validationMessage: input.validationMessage,
+        },
+        bubbles: true,
+        composed: true,
+      }),
+    );
+  };
+
+  private readonly onChange = (e: Event) => {
+    if (!this.column) return;
+
+    e.stopPropagation();
+
+    const input = e.target as InputElement;
+
+    this.dispatchEvent(
+      new CustomEvent<CellEditEventDetail>("btrix-change", {
+        detail: {
+          field: this.column.field,
+          value: input.value,
+          validity: input.validity,
+          validationMessage: input.validationMessage,
+        },
+        bubbles: true,
+        composed: true,
+      }),
+    );
+  };
+}
diff --git a/frontend/src/components/ui/data-grid/data-grid-row.ts b/frontend/src/components/ui/data-grid/data-grid-row.ts
new file mode 100644
index 0000000000..73b01e39a4
--- /dev/null
+++ b/frontend/src/components/ui/data-grid/data-grid-row.ts
@@ -0,0 +1,384 @@
+import { localized, msg } from "@lit/localize";
+import clsx from "clsx";
+import { html, type PropertyValues } from "lit";
+import { customElement, property, queryAll, state } from "lit/decorators.js";
+import { directive } from "lit/directive.js";
+import isEqual from "lodash/fp/isEqual";
+
+import { CellDirective } from "./cellDirective";
+import type {
+  CellEditEventDetail,
+  DataGridCell,
+  InputElement,
+} from "./data-grid-cell";
+import type { GridColumn, GridItem, GridRowId } from "./types";
+
+import { DataGridFocusController } from "@/components/ui/data-grid/controllers/focus";
+import { TableRow } from "@/components/ui/table/table-row";
+import { tw } from "@/utils/tailwind";
+
+export type RowRemoveEventDetail = {
+  key?: string;
+};
+
+const cell = directive(CellDirective);
+
+const cellStyle = tw`focus-visible:-outline-offset-2`;
+const editableCellStyle = tw`p-0 focus-visible:bg-slate-50 `;
+
+/**
+ * @fires btrix-remove CustomEvent
+ */
+@customElement("btrix-data-grid-row")
+@localized()
+export class DataGridRow extends TableRow {
+  // TODO Abstract to mixin or decorator
+  // https://github.com/webrecorder/browsertrix/issues/2577
+  static formAssociated = true;
+  readonly #internals: ElementInternals;
+
+  /**
+   * Set of columns.
+   */
+  @property({ type: Array })
+  columns?: GridColumn[] = [];
+
+  /**
+   * Row key/ID.
+   */
+  @property({ type: String })
+  key?: GridRowId;
+
+  /**
+   * Data to be presented as a row.
+   */
+  @property({ type: Object, hasChanged: (a, b) => !isEqual(a, b) })
+  item?: GridItem;
+
+  /**
+   * Whether the row can be removed.
+   */
+  @property({ type: Boolean })
+  removable = false;
+
+  /**
+   * Whether cells can be edited.
+   */
+  @property({ type: Boolean })
+  editCells = false;
+
+  /**
+   * Form control name, if used in a form.
+   */
+  @property({ type: String, reflect: true })
+  name?: string;
+
+  /**
+   * Make row focusable on validation.
+   */
+  @property({ type: Number, reflect: true })
+  tabindex = 0;
+
+  @state()
+  private cellValues: Partial<GridItem> = {};
+
+  readonly #focus = new DataGridFocusController(this);
+
+  readonly #invalidInputsMap = new Map<
+    GridColumn["field"],
+    InputElement["validationMessage"]
+  >();
+
+  public formAssociatedCallback() {
+    console.debug("form associated");
+  }
+
+  public formResetCallback() {
+    this.setValue(this.item || {});
+    this.commitValue();
+  }
+
+  public formDisabledCallback(disabled: boolean) {
+    console.debug("form disabled:", disabled);
+  }
+
+  public formStateRestoreCallback(state: string | FormData, reason: string) {
+    console.debug("formStateRestoreCallback:", state, reason);
+  }
+
+  public checkValidity(): boolean {
+    return this.#internals.checkValidity();
+  }
+
+  public reportValidity(): void {
+    this.#internals.reportValidity();
+  }
+
+  public get validity(): ValidityState {
+    return this.#internals.validity;
+  }
+
+  public get validationMessage(): string {
+    return this.#internals.validationMessage;
+  }
+
+  constructor() {
+    super();
+    this.#internals = this.attachInternals();
+  }
+
+  protected createRenderRoot() {
+    const root = super.createRenderRoot();
+
+    // Attach to render root so that `e.target` is table cell
+    root.addEventListener(
+      "btrix-input",
+      (e) => void this.onCellInput(e as CustomEvent<CellEditEventDetail>),
+    );
+    root.addEventListener(
+      "btrix-change",
+      (e) => void this.onCellChange(e as CustomEvent<CellEditEventDetail>),
+    );
+
+    return root;
+  }
+
+  protected willUpdate(changedProperties: PropertyValues): void {
+    if (
+      (changedProperties.has("item") || changedProperties.has("editCells")) &&
+      this.item &&
+      this.editCells
+    ) {
+      this.setValue(this.item);
+      this.commitValue();
+    }
+  }
+
+  @queryAll("btrix-data-grid-cell")
+  private readonly gridCells?: NodeListOf<DataGridCell>;
+
+  private setValue(cellValues: Partial<GridItem>) {
+    Object.keys(cellValues).forEach((field) => {
+      this.cellValues[field] = cellValues[field];
+    });
+
+    this.#internals.setFormValue(JSON.stringify(this.cellValues));
+  }
+
+  private commitValue() {
+    this.cellValues = {
+      ...this.cellValues,
+    };
+  }
+
+  render() {
+    if (!this.columns?.length) return html``;
+
+    let removeCell = html``;
+
+    if (this.removable) {
+      removeCell = html`
+        <btrix-data-grid-cell
+          class=${clsx(tw`border-l p-0`, cellStyle)}
+          @keydown=${this.onKeydown}
+        >
+          <sl-tooltip content=${msg("Remove")} hoist>
+            <sl-icon-button
+              class="p-1 text-base hover:text-danger"
+              name="trash3"
+              @click=${() =>
+                this.dispatchEvent(
+                  new CustomEvent<RowRemoveEventDetail>("btrix-remove", {
+                    detail: {
+                      key: this.key,
+                    },
+                    bubbles: true,
+                    composed: true,
+                  }),
+                )}
+            ></sl-icon-button>
+          </sl-tooltip>
+        </btrix-data-grid-cell>
+      `;
+    }
+
+    return html`${this.columns.map(this.renderCell)}${removeCell}`;
+  }
+
+  private readonly renderCell = (col: GridColumn, i: number) => {
+    const validationMessage = this.#invalidInputsMap.get(col.field);
+    const editable = this.editCells && col.editable;
+
+    return html`
+      <sl-tooltip
+        ?disabled=${!validationMessage}
+        content=${validationMessage || ""}
+        hoist
+        placement="bottom"
+        trigger=${
+          // Manually show/hide tooltip on blur/focus
+          "manual"
+        }
+      >
+        <btrix-data-grid-cell
+          class=${clsx(
+            i > 0 && tw`border-l`,
+            cellStyle,
+            editable && editableCellStyle,
+          )}
+          .column=${col}
+          .item=${this.item}
+          ?editable=${editable}
+          ${cell(col)}
+          @keydown=${this.onKeydown}
+          @focus=${(e: CustomEvent) => {
+            e.stopPropagation();
+
+            const tableCell = e.target as DataGridCell;
+            const tooltip = tableCell.closest("sl-tooltip");
+
+            if (tooltip?.open) {
+              void tooltip.hide();
+            }
+          }}
+          @blur=${(e: CustomEvent) => {
+            e.stopPropagation();
+
+            const tableCell = e.target as DataGridCell;
+            const tooltip = tableCell.closest("sl-tooltip");
+
+            if (tooltip && !tooltip.disabled) {
+              void tooltip.show();
+            }
+          }}
+        ></btrix-data-grid-cell>
+      </sl-tooltip>
+    `;
+  };
+
+  /**
+   * Keyboard navigation based on recommendations from
+   * https://www.w3.org/WAI/ARIA/apg/patterns/grid/#keyboardinteraction-settingfocusandnavigatinginsidecells
+   */
+  private onKeydown(e: KeyboardEvent) {
+    const tableCell = e.currentTarget as DataGridCell;
+    const composedTarget = e.composedPath()[0] as HTMLElement;
+
+    if (composedTarget === tableCell) {
+      if (!this.gridCells) {
+        console.debug("no grid cells");
+        return;
+      }
+
+      const gridCells = Array.from(this.gridCells);
+      const i = gridCells.indexOf(e.target as DataGridCell);
+
+      if (i === -1) return;
+
+      const findNextTabbable = (idx: number, direction: -1 | 1) => {
+        const el = gridCells[idx + direction];
+
+        if (!(el as unknown)) return;
+
+        if (this.#focus.isTabbable(el)) {
+          e.preventDefault();
+
+          el.focus();
+        } else {
+          findNextTabbable(idx + direction, direction);
+        }
+      };
+
+      switch (e.key) {
+        case "ArrowRight":
+        case "ArrowDown": {
+          findNextTabbable(i, 1);
+          break;
+        }
+        case "ArrowLeft":
+        case "ArrowUp": {
+          findNextTabbable(i, -1);
+          break;
+        }
+        case "Tab": {
+          // Check if tabbing was prevented, likely by the focus controller
+          if (e.defaultPrevented) {
+            findNextTabbable(i, 1);
+          }
+          break;
+        }
+        default:
+          break;
+      }
+    } else {
+      if (e.key === "Escape") {
+        const tabIndex = composedTarget.tabIndex;
+
+        // Temporarily disable focusable child so that focus
+        // doesn't move when exiting
+        composedTarget.setAttribute("tabindex", "-1");
+        // Exit back into grid navigation
+        tableCell.focus();
+        // Reinstate focusable child
+        composedTarget.setAttribute("tabindex", `${tabIndex}`);
+      }
+    }
+  }
+
+  private readonly onCellInput = async (
+    e: CustomEvent<CellEditEventDetail>,
+  ) => {
+    e.stopPropagation();
+
+    const { field, value, validity, validationMessage } = e.detail;
+    const tableCell = e.target as DataGridCell;
+
+    if (validity.valid) {
+      this.#invalidInputsMap.delete(field);
+    } else {
+      this.#invalidInputsMap.set(field, validationMessage);
+      this.#internals.setValidity(validity, validationMessage, tableCell);
+    }
+
+    this.setValue({
+      [field]: value.toString(),
+    });
+  };
+
+  private readonly onCellChange = async (
+    e: CustomEvent<CellEditEventDetail>,
+  ) => {
+    e.stopPropagation();
+
+    const { field, validity, validationMessage } = e.detail;
+    const tableCell = e.target as DataGridCell;
+
+    if (validity.valid) {
+      this.#invalidInputsMap.delete(field);
+    } else {
+      this.#invalidInputsMap.set(field, validationMessage);
+      this.#internals.setValidity(validity, validationMessage, tableCell);
+    }
+
+    this.commitValue();
+
+    await this.updateComplete;
+    await tableCell.input?.updateComplete;
+
+    if (validity.valid) {
+      const firstInvalid = Array.from(this.gridCells || []).find((cell) =>
+        cell.validity?.valid ? false : cell,
+      );
+
+      if (firstInvalid?.validity && firstInvalid.validationMessage) {
+        this.#internals.setValidity(
+          firstInvalid.validity,
+          firstInvalid.validationMessage,
+          firstInvalid,
+        );
+      } else {
+        this.#internals.setValidity({});
+      }
+    }
+  };
+}
diff --git a/frontend/src/components/ui/data-grid/data-grid.ts b/frontend/src/components/ui/data-grid/data-grid.ts
new file mode 100644
index 0000000000..49c5055be7
--- /dev/null
+++ b/frontend/src/components/ui/data-grid/data-grid.ts
@@ -0,0 +1,319 @@
+import { localized, msg } from "@lit/localize";
+import type { SlChangeEvent, SlInput } from "@shoelace-style/shoelace";
+import clsx from "clsx";
+import { css, html, nothing } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+import { nanoid } from "nanoid";
+import type { EmptyObject } from "type-fest";
+
+import { DataGridRowsController } from "./controllers/rows";
+import type { DataGridRow, RowRemoveEventDetail } from "./data-grid-row";
+import { renderRows } from "./renderRows";
+import type { GridColumn, GridItem } from "./types";
+
+import { TailwindElement } from "@/classes/TailwindElement";
+import { pluralOf } from "@/utils/pluralize";
+import { tw } from "@/utils/tailwind";
+
+/**
+ * Data grids structure data into rows and and columns.
+ *
+ * @slot label
+ * @slot rows
+ * @fires btrix-change
+ * @fires btrix-remove
+ */
+@customElement("btrix-data-grid")
+@localized()
+export class DataGrid extends TailwindElement {
+  static styles = css`
+    :host {
+      --border: 1px solid var(--sl-panel-border-color);
+    }
+
+    btrix-data-grid-row:not(:first-of-type),
+    btrix-table-body ::slotted(*:nth-of-type(n + 2)) {
+      border-top: var(--border) !important;
+    }
+
+    btrix-data-grid-row,
+    btrix-table-body ::slotted(btrix-data-grid-row) {
+      /* TODO Support different input sizes */
+      min-height: calc(var(--sl-input-height-medium) + 1px);
+    }
+  `;
+
+  /**
+   * Set of columns.
+   */
+  @property({ type: Array })
+  columns?: GridColumn[];
+
+  /**
+   * Set of data to be presented as rows. Omit if using the `rows` slot.
+   */
+  @property({ type: Array })
+  items?: GridItem[];
+
+  /**
+   * Stick header row to the top of the viewport.
+   */
+  @property({ type: Boolean })
+  stickyHeader = false;
+
+  /**
+   * Item key to use as the row key, like an ID.
+   * Defaults to one generated by nanoid.
+   */
+  @property({ type: String })
+  rowKey?: string;
+
+  /**
+   * Whether rows can be removed.
+   */
+  @property({ type: Boolean })
+  removeRows = false;
+
+  /**
+   * Whether rows can be added.
+   */
+  @property({ type: Boolean })
+  addRows = false;
+
+  /**
+   * Make the number of rows being added configurable,
+   * with a default starting value.
+   */
+  @property({ type: Number })
+  addRowsInputValue?: number;
+
+  /**
+   * Whether cells can be edited.
+   */
+  @property({ type: Boolean })
+  editCells = false;
+
+  /**
+   * Disable an editable grid.
+   */
+  @property({ type: Boolean })
+  disabled?: boolean;
+
+  /**
+   * Default item for new rows.
+   */
+  @property({ type: Object })
+  defaultItem?: EmptyObject | GridItem = {};
+
+  /**
+   * Text for form control label. Use slot to include markup.
+   */
+  @property({ type: String })
+  formControlLabel?: string;
+
+  /**
+   * ID for form control label.
+   */
+  @property({ type: String })
+  formControlLabelId = `label-${nanoid()}`;
+
+  /**
+   * Optional external controller for removing and adding rows,
+   * if rendering rows into the `rows` slot.
+   */
+  @property({ attribute: false })
+  rowsController = new DataGridRowsController(this);
+
+  /**
+   * Make grid focusable on validation.
+   */
+  @property({ type: Number, reflect: true })
+  tabindex = 0;
+
+  render() {
+    if (!this.columns?.length) return;
+
+    const cssWidths = this.columns.map((col) => col.width ?? "1fr");
+
+    return html`
+      <slot name="label">
+        <label id=${this.formControlLabelId} class="form-label text-xs">
+          ${this.formControlLabel}
+        </label>
+      </slot>
+
+      <btrix-table
+        role="grid"
+        class=${clsx(
+          tw`relative size-full overflow-auto`,
+          this.stickyHeader && tw`rounded border`,
+        )}
+        style="--btrix-table-grid-template-columns: ${cssWidths.join(" ")}${this
+          .removeRows
+          ? " max-content"
+          : ""}"
+        aria-labelledby=${ifDefined(
+          (this.formControlLabel && this.formControlLabelId) ?? undefined,
+        )}
+        aria-readonly=${ifDefined(this.disabled)}
+      >
+        <btrix-table-head
+          class=${clsx(
+            tw`[--btrix-table-cell-padding:var(--sl-spacing-x-small)]`,
+            this.stickyHeader
+              ? tw`sticky top-0 z-10 rounded-t-[0.1875rem] border-b bg-neutral-50 [&>*:not(:first-of-type)]:border-l`
+              : tw`px-px`,
+          )}
+        >
+          ${this.columns.map(
+            (col) => html`
+              <btrix-table-header-cell>
+                ${col.label}
+                ${col.description
+                  ? html`
+                      <sl-tooltip content=${col.description}>
+                        <sl-icon
+                          name="info-circle"
+                          class="ml-1.5 align-[-.175em] text-sm text-slate-500"
+                        ></sl-icon>
+                      </sl-tooltip>
+                    `
+                  : nothing}
+              </btrix-table-header-cell>
+            `,
+          )}
+          ${this.removeRows
+            ? html`<btrix-table-header-cell>
+                <span class="sr-only">${msg("Remove row")}</span>
+              </btrix-table-header-cell>`
+            : nothing}
+        </btrix-table-head>
+        <btrix-table-body
+          class=${clsx(
+            tw`[--btrix-table-cell-padding:var(--sl-spacing-x-small)]`,
+            tw`leading-none`,
+            !this.stickyHeader && tw`rounded border`,
+          )}
+          @btrix-remove=${(e: CustomEvent<RowRemoveEventDetail>) => {
+            const { key } = e.detail;
+
+            if (key) {
+              this.rowsController.removeRow(key);
+            } else {
+              console.warn("Could not remove row without key or item");
+            }
+          }}
+        >
+          ${this.renderRows()}
+          ${this.addRows && this.addRowsInputValue
+            ? html`
+                <btrix-table-row class="border-t">
+                  <btrix-table-cell class="col-span-full px-1">
+                    <!-- TODO Replace navigation button -->
+                    <btrix-navigation-button
+                      size="small"
+                      @click=${() =>
+                        this.rowsController.addRows(
+                          this.defaultItem,
+                          this.addRowsInputValue,
+                        )}
+                    >
+                      <sl-icon name="plus-lg"></sl-icon>
+                      ${msg("Add")}
+                    </btrix-navigation-button>
+                    <btrix-inline-input
+                      value=${this.addRowsInputValue}
+                      min="1"
+                      max="99"
+                      minlength="1"
+                      maxlength="2"
+                      class="ml-1 w-10"
+                      @sl-change=${(e: SlChangeEvent) => {
+                        const input = e.target as SlInput;
+                        const value = +input.value;
+
+                        this.addRowsInputValue = Math.max(1, value);
+                        input.value = `${this.addRowsInputValue}`;
+                      }}
+                    ></btrix-inline-input>
+                    <span class="ml-2.5 text-neutral-500">
+                      ${msg("more")} ${pluralOf("rows", this.addRowsInputValue)}
+                    </span>
+                  </btrix-table-cell>
+                </btrix-table-row>
+              `
+            : nothing}
+        </btrix-table-body>
+      </btrix-table>
+
+      ${this.addRows && !this.addRowsInputValue
+        ? this.renderAddButton()
+        : nothing}
+    `;
+  }
+
+  private renderRows() {
+    return html`
+      <slot name="rows" class="contents" @slotchange=${this.onRowSlotChange}>
+        ${this.items
+          ? renderRows(
+              this.rowsController.rows,
+              ({ id, item }) => html`
+                <btrix-data-grid-row
+                  key=${id}
+                  .item=${item}
+                  .columns=${this.columns}
+                  ?removable=${this.removeRows}
+                  ?editCells=${this.editCells}
+                ></btrix-data-grid-row>
+              `,
+            )
+          : nothing}
+      </slot>
+    `;
+  }
+
+  private readonly renderAddButton = () => {
+    return html`<footer class="mt-2">
+      <sl-button
+        size="small"
+        class="w-full"
+        @click=${() => this.rowsController.addRows(this.defaultItem)}
+      >
+        <sl-icon slot="prefix" name="plus-lg"></sl-icon>
+        <span class="text-neutral-600">${msg("Add More")}</span>
+      </sl-button>
+    </footer>`;
+  };
+
+  private readonly onRowSlotChange = (e: Event) => {
+    const rows = (e.target as HTMLSlotElement).assignedElements();
+    const assignProp = (
+      el: Element,
+      { name, value }: { name: keyof DataGridRow; value: string | boolean },
+    ) => {
+      if (el.attributes.getNamedItem(name)) return;
+
+      if (typeof value === "boolean") {
+        if (value) {
+          el.setAttribute(name, "true");
+        } else {
+          el.removeAttribute(name);
+        }
+      } else {
+        el.setAttribute(name, value);
+      }
+    };
+
+    const removable = this.removeRows;
+    const editCells = this.editCells;
+
+    rows.forEach((el) => {
+      assignProp(el, { name: "removable", value: removable });
+      assignProp(el, { name: "editCells", value: editCells });
+
+      (el as DataGridRow)["columns"] = this.columns;
+    });
+  };
+}
diff --git a/frontend/src/components/ui/data-grid/index.ts b/frontend/src/components/ui/data-grid/index.ts
new file mode 100644
index 0000000000..8d7ac436c4
--- /dev/null
+++ b/frontend/src/components/ui/data-grid/index.ts
@@ -0,0 +1,3 @@
+import "./data-grid";
+import "./data-grid-cell";
+import "./data-grid-row";
diff --git a/frontend/src/components/ui/data-grid/renderRows.ts b/frontend/src/components/ui/data-grid/renderRows.ts
new file mode 100644
index 0000000000..e3a436ac40
--- /dev/null
+++ b/frontend/src/components/ui/data-grid/renderRows.ts
@@ -0,0 +1,15 @@
+import { type TemplateResult } from "lit";
+import { repeat } from "lit/directives/repeat.js";
+
+import type { GridItem, GridRowId, GridRows } from "./types";
+
+export function renderRows<T = GridItem>(
+  rows: GridRows<GridItem>,
+  renderRow: ({ id, item }: { id: GridRowId; item: T }) => TemplateResult,
+) {
+  return repeat(
+    rows,
+    ([id]) => id,
+    ([id, item]) => renderRow({ id, item: item as T }),
+  );
+}
diff --git a/frontend/src/components/ui/data-grid/types.ts b/frontend/src/components/ui/data-grid/types.ts
new file mode 100644
index 0000000000..c122529eec
--- /dev/null
+++ b/frontend/src/components/ui/data-grid/types.ts
@@ -0,0 +1,45 @@
+import type { TemplateResult } from "lit";
+import { z } from "zod";
+
+export type GridItem<T extends PropertyKey = string> = Record<
+  T,
+  string | number | null | undefined
+>;
+
+export enum GridColumnType {
+  Text = "text",
+  Number = "number",
+  URL = "url",
+  // Syntax = "syntax",
+  Select = "select",
+}
+
+export type GridColumnSelectType = {
+  inputType: GridColumnType.Select;
+  selectOptions: {
+    value: string;
+    label?: string | TemplateResult;
+  }[];
+};
+
+export type GridColumn<T = string> = {
+  field: T;
+  label: string | TemplateResult;
+  description?: string;
+  editable?: boolean;
+  required?: boolean;
+  inputPlaceholder?: string;
+  width?: string;
+  renderEditCell?: ({ item }: { item: GridItem }) => TemplateResult<1>;
+  renderCell?: ({ item }: { item: GridItem }) => TemplateResult<1>;
+} & (
+  | {
+      inputType?: GridColumnType;
+    }
+  | GridColumnSelectType
+);
+
+const rowIdSchema = z.string().nanoid();
+export type GridRowId = z.infer<typeof rowIdSchema>;
+
+export interface GridRows<T> extends Map<GridRowId, T> {}
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
index c7c9f96f61..91deadc6f4 100644
--- a/frontend/src/components/ui/index.ts
+++ b/frontend/src/components/ui/index.ts
@@ -16,6 +16,7 @@ import("./combobox");
 import("./config-details");
 import("./copy-button");
 import("./copy-field");
+import("./data-grid");
 import("./details");
 import("./file-list");
 import("./format-date");
diff --git a/frontend/src/components/ui/inline-input.ts b/frontend/src/components/ui/inline-input.ts
index 315b5e7937..b4f566e88b 100644
--- a/frontend/src/components/ui/inline-input.ts
+++ b/frontend/src/components/ui/inline-input.ts
@@ -1,12 +1,27 @@
 import SlInput from "@shoelace-style/shoelace/dist/components/input/input.js";
 import { css } from "lit";
-import { customElement } from "lit/decorators.js";
+import { customElement, property } from "lit/decorators.js";
 
 /**
  * Input to use inline with text.
+ *
+ * @attr value
+ * @attr max
+ * @attr min
+ * @attr maxlength
+ * @attr minlength
  */
 @customElement("btrix-inline-input")
 export class InlineInput extends SlInput {
+  @property({ type: String, reflect: true })
+  size: SlInput["size"] = "small";
+
+  @property({ type: String, reflect: true })
+  inputmode: SlInput["inputmode"] = "numeric";
+
+  @property({ type: String, reflect: true })
+  autocomplete: SlInput["autocomplete"] = "off";
+
   static styles = [
     SlInput.styles,
     css`
diff --git a/frontend/src/components/ui/syntax-input.ts b/frontend/src/components/ui/syntax-input.ts
index 7be71679a6..a0ae4b6217 100644
--- a/frontend/src/components/ui/syntax-input.ts
+++ b/frontend/src/components/ui/syntax-input.ts
@@ -8,6 +8,7 @@ import clsx from "clsx";
 import { html } from "lit";
 import { customElement, property, query, state } from "lit/decorators.js";
 import { ifDefined } from "lit/directives/if-defined.js";
+import type { EmptyObject } from "type-fest";
 
 import { TailwindElement } from "@/classes/TailwindElement";
 import type { Code } from "@/components/ui/code";
@@ -16,6 +17,10 @@ import { tw } from "@/utils/tailwind";
 /**
  * Basic text input with code syntax highlighting
  *
+ * @TODO Refactor to use `ElementInternals`
+ * https://github.com/webrecorder/browsertrix/issues/2577
+ *
+ * @fires btrix-input
  * @fires btrix-change
  */
 @customElement("btrix-syntax-input")
@@ -65,6 +70,10 @@ export class SyntaxInput extends TailwindElement {
   @query("btrix-code")
   private readonly code?: Code | null;
 
+  public get validity(): ValidityState | EmptyObject {
+    return this.input?.validity || {};
+  }
+
   public setCustomValidity(message: string) {
     this.input?.setCustomValidity(message);
     if (this.disableTooltip) {
@@ -150,6 +159,13 @@ export class SyntaxInput extends TailwindElement {
 
               await this.code.updateComplete;
 
+              this.dispatchEvent(
+                new CustomEvent("btrix-input", {
+                  detail: { value },
+                  bubbles: true,
+                }),
+              );
+
               void this.scrollSync({ pad: true });
             }
           }}
@@ -184,6 +200,7 @@ export class SyntaxInput extends TailwindElement {
               this.dispatchEvent(
                 new CustomEvent("btrix-change", {
                   detail: { value: this.code.value },
+                  bubbles: true,
                 }),
               );
             }
diff --git a/frontend/src/components/ui/table/table-row.ts b/frontend/src/components/ui/table/table-row.ts
index 043fcfe3fb..fea948593d 100644
--- a/frontend/src/components/ui/table/table-row.ts
+++ b/frontend/src/components/ui/table/table-row.ts
@@ -1,8 +1,10 @@
-import { css, html, LitElement } from "lit";
+import { css, html } from "lit";
 import { customElement, property } from "lit/decorators.js";
 
+import { TailwindElement } from "@/classes/TailwindElement";
+
 @customElement("btrix-table-row")
-export class TableRow extends LitElement {
+export class TableRow extends TailwindElement {
   static styles = css`
     :host {
       grid-column: 1 / -1;
diff --git a/frontend/src/components/ui/table/table.ts b/frontend/src/components/ui/table/table.ts
index 46b0be8376..f405ca98d1 100644
--- a/frontend/src/components/ui/table/table.ts
+++ b/frontend/src/components/ui/table/table.ts
@@ -19,6 +19,8 @@ tableCSS.split("}").forEach((rule: string) => {
 });
 
 /**
+ * @deprecated Use `<btrix-data-grid>` instead.
+ *
  * Low-level component for displaying content into columns and rows.
  * To style tables, use TailwindCSS utility classes.
  * To render styled, tabular data, use `<btrix-data-table>`.
diff --git a/frontend/src/components/ui/url-input.ts b/frontend/src/components/ui/url-input.ts
index bc41bc0402..9b5c16e11d 100644
--- a/frontend/src/components/ui/url-input.ts
+++ b/frontend/src/components/ui/url-input.ts
@@ -29,6 +29,9 @@ export class UrlInput extends SlInput {
   @property({ type: String, reflect: true })
   placeholder = "https://example.com";
 
+  @property({ type: Boolean })
+  hideHelpText = false;
+
   constructor() {
     super();
 
@@ -48,7 +51,7 @@ export class UrlInput extends SlInput {
   private readonly onInput = () => {
     if (!this.checkValidity() && validURL(this.value)) {
       this.setCustomValidity("");
-      this.helpText = "";
+      if (!this.hideHelpText) this.helpText = "";
     }
   };
 
@@ -57,7 +60,7 @@ export class UrlInput extends SlInput {
 
     if (value && !validURL(value)) {
       const text = msg("Please enter a valid URL.");
-      this.helpText = text;
+      if (!this.hideHelpText) this.helpText = text;
       this.setCustomValidity(text);
     } else if (
       value &&
diff --git a/frontend/src/features/org/org-status-banner.ts b/frontend/src/features/org/org-status-banner.ts
index c780b17b50..0999c6b976 100644
--- a/frontend/src/features/org/org-status-banner.ts
+++ b/frontend/src/features/org/org-status-banner.ts
@@ -157,7 +157,8 @@ export class OrgStatusBanner extends BtrixElement {
         },
       },
       {
-        test: () => !readOnly && readOnlyOnCancel && !!futureCancelDate,
+        test: () =>
+          !readOnly && (readOnlyOnCancel ?? false) && !!futureCancelDate,
 
         content: () => {
           return {
diff --git a/frontend/src/features/org/usage-history-table.ts b/frontend/src/features/org/usage-history-table.ts
index 6780e04de3..135780114a 100644
--- a/frontend/src/features/org/usage-history-table.ts
+++ b/frontend/src/features/org/usage-history-table.ts
@@ -3,8 +3,19 @@ import { html } from "lit";
 import { customElement } from "lit/decorators.js";
 
 import { BtrixElement } from "@/classes/BtrixElement";
+import type { GridColumn, GridItem } from "@/components/ui/data-grid/types";
+import { noData } from "@/strings/ui";
 import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter";
 
+enum Field {
+  Month = "month",
+  ElapsedTime = "elapsedTime",
+  ExecutionTime = "executionTime",
+  BillableExecutionTime = "billableExecutionTime",
+  RolloverExecutionTime = "rolloverExecutionTime",
+  GiftedExecutionTime = "giftedExecutionTime",
+}
+
 @customElement("btrix-usage-history-table")
 @localized()
 export class UsageHistoryTable extends BtrixElement {
@@ -22,7 +33,10 @@ export class UsageHistoryTable extends BtrixElement {
   render() {
     if (!this.org) return;
 
-    if (this.org.usage && !Object.keys(this.org.usage).length) {
+    const org = this.org;
+    const usageEntries = Object.entries(org.usage || {});
+
+    if (!usageEntries.length) {
       return html`
         <p
           class="rounded border bg-neutral-50 p-3 text-center text-neutral-500"
@@ -32,160 +46,129 @@ export class UsageHistoryTable extends BtrixElement {
       `;
     }
 
-    const usageTableCols = [
-      msg("Month"),
-      html`
-        ${msg("Elapsed Time")}
-        <sl-tooltip>
-          <div slot="content" style="text-transform: initial">
-            ${msg(
-              "Total duration of crawls and QA analysis runs, from start to finish",
-            )}
-          </div>
-          <sl-icon name="info-circle" style="vertical-align: -.175em"></sl-icon>
-        </sl-tooltip>
-      `,
-      html`
-        ${msg("Execution Time")}
-        <sl-tooltip>
-          <div slot="content" style="text-transform: initial">
-            ${msg(
-              "Aggregated time across all browser windows that the crawler was actively executing a crawl or QA analysis run, i.e. not in a waiting state",
-            )}
-          </div>
-          <sl-icon name="info-circle" style="vertical-align: -.175em"></sl-icon>
-        </sl-tooltip>
-      `,
+    const cols: GridColumn<Field>[] = [
+      {
+        field: Field.Month,
+        label: msg("Month"),
+        renderCell({ item }) {
+          return html`<btrix-format-date
+            date="${item.month}-15T00:00:00.000Z"
+            time-zone="utc"
+            month="long"
+            year="numeric"
+          >
+          </btrix-format-date>`;
+        },
+      },
+      {
+        field: Field.ElapsedTime,
+        label: msg("Elapsed Time"),
+        description: msg(
+          "Total duration of crawls and QA analysis runs, from start to finish",
+        ),
+      },
+      {
+        field: Field.ExecutionTime,
+        label: msg("Execution Time"),
+        description: msg(
+          "Aggregated time across all browser windows that the crawler was actively executing a crawl or QA analysis run, i.e. not in a waiting state",
+        ),
+      },
     ];
 
     if (this.hasMonthlyTime()) {
-      usageTableCols.push(
-        html`${msg("Billable Execution Time")}
-          <sl-tooltip>
-            <div slot="content" style="text-transform: initial">
-              ${msg(
-                "Execution time used that is billable to the current month of the plan",
-              )}
-            </div>
-            <sl-icon
-              name="info-circle"
-              style="vertical-align: -.175em"
-            ></sl-icon>
-          </sl-tooltip>`,
-      );
+      cols.push({
+        field: Field.BillableExecutionTime,
+        label: msg("Billable Execution Time"),
+        description: msg(
+          "Execution time used that is billable to the current month of the plan",
+        ),
+      });
     }
     if (this.hasExtraTime()) {
-      usageTableCols.push(
-        html`${msg("Rollover Execution Time")}
-          <sl-tooltip>
-            <div slot="content" style="text-transform: initial">
-              ${msg(
-                "Additional execution time used, of which any extra minutes will roll over to next month as billable time",
-              )}
-            </div>
-            <sl-icon
-              name="info-circle"
-              style="vertical-align: -.175em"
-            ></sl-icon>
-          </sl-tooltip>`,
-      );
+      cols.push({
+        field: Field.RolloverExecutionTime,
+        label: msg("Rollover Execution Time"),
+        description: msg(
+          "Additional execution time used, of which any extra minutes will roll over to next month as billable time",
+        ),
+      });
     }
     if (this.hasGiftedTime()) {
-      usageTableCols.push(
-        html`${msg("Gifted Execution Time")}
-          <sl-tooltip>
-            <div slot="content" style="text-transform: initial">
-              ${msg("Execution time used that is free of charge")}
-            </div>
-            <sl-icon
-              name="info-circle"
-              style="vertical-align: -.175em"
-            ></sl-icon>
-          </sl-tooltip>`,
-      );
+      cols.push({
+        field: Field.GiftedExecutionTime,
+        label: msg("Gifted Execution Time"),
+        description: msg("Execution time used that is free of charge"),
+      });
     }
 
-    const rows = Object.entries(this.org.usage || {})
-      // Sort latest
-      .reverse()
-      .map(([mY, crawlTime]) => {
-        if (!this.org) return [];
-
-        let monthlySecondsUsed = this.org.monthlyExecSeconds?.[mY] || 0;
-        let maxMonthlySeconds = 0;
-        if (this.org.quotas.maxExecMinutesPerMonth) {
-          maxMonthlySeconds = this.org.quotas.maxExecMinutesPerMonth * 60;
-        }
-        if (monthlySecondsUsed > maxMonthlySeconds) {
-          monthlySecondsUsed = maxMonthlySeconds;
-        }
-
-        let extraSecondsUsed = this.org.extraExecSeconds?.[mY] || 0;
-        let maxExtraSeconds = 0;
-        if (this.org.quotas.extraExecMinutes) {
-          maxExtraSeconds = this.org.quotas.extraExecMinutes * 60;
-        }
-        if (extraSecondsUsed > maxExtraSeconds) {
-          extraSecondsUsed = maxExtraSeconds;
-        }
-
-        let giftedSecondsUsed = this.org.giftedExecSeconds?.[mY] || 0;
-        let maxGiftedSeconds = 0;
-        if (this.org.quotas.giftedExecMinutes) {
-          maxGiftedSeconds = this.org.quotas.giftedExecMinutes * 60;
-        }
-        if (giftedSecondsUsed > maxGiftedSeconds) {
-          giftedSecondsUsed = maxGiftedSeconds;
-        }
-
-        let totalSecondsUsed = this.org.crawlExecSeconds?.[mY] || 0;
-        const totalMaxQuota =
-          maxMonthlySeconds + maxExtraSeconds + maxGiftedSeconds;
-        if (totalSecondsUsed > totalMaxQuota) {
-          totalSecondsUsed = totalMaxQuota;
-        }
-
-        const tableRows = [
-          html`
-            <btrix-format-date
-              date="${mY}-15T00:00:00.000Z"
-              time-zone="utc"
-              month="long"
-              year="numeric"
-            >
-            </btrix-format-date>
-          `,
-          humanizeExecutionSeconds(crawlTime || 0),
-          totalSecondsUsed ? humanizeExecutionSeconds(totalSecondsUsed) : "--",
-        ];
-        if (this.hasMonthlyTime()) {
-          tableRows.push(
-            monthlySecondsUsed
-              ? humanizeExecutionSeconds(monthlySecondsUsed)
-              : "--",
-          );
-        }
-        if (this.hasExtraTime()) {
-          tableRows.push(
-            extraSecondsUsed
-              ? humanizeExecutionSeconds(extraSecondsUsed)
-              : "--",
-          );
-        }
-        if (this.hasGiftedTime()) {
-          tableRows.push(
-            giftedSecondsUsed
-              ? humanizeExecutionSeconds(giftedSecondsUsed)
-              : "--",
-          );
-        }
-        return tableRows;
-      });
+    cols.forEach((col) => {
+      if (!col.renderCell) {
+        col.renderCell = this.renderSecondsForField(col.field);
+      }
+    });
+
+    const items: GridItem[] = [];
+
+    usageEntries.forEach(([mY, crawlTime]) => {
+      let monthlySecondsUsed = org.monthlyExecSeconds?.[mY] || 0;
+      let maxMonthlySeconds = 0;
+      if (org.quotas.maxExecMinutesPerMonth) {
+        maxMonthlySeconds = org.quotas.maxExecMinutesPerMonth * 60;
+      }
+      if (monthlySecondsUsed > maxMonthlySeconds) {
+        monthlySecondsUsed = maxMonthlySeconds;
+      }
+
+      let extraSecondsUsed = org.extraExecSeconds?.[mY] || 0;
+      let maxExtraSeconds = 0;
+      if (org.quotas.extraExecMinutes) {
+        maxExtraSeconds = org.quotas.extraExecMinutes * 60;
+      }
+      if (extraSecondsUsed > maxExtraSeconds) {
+        extraSecondsUsed = maxExtraSeconds;
+      }
+
+      let giftedSecondsUsed = org.giftedExecSeconds?.[mY] || 0;
+      let maxGiftedSeconds = 0;
+      if (org.quotas.giftedExecMinutes) {
+        maxGiftedSeconds = org.quotas.giftedExecMinutes * 60;
+      }
+      if (giftedSecondsUsed > maxGiftedSeconds) {
+        giftedSecondsUsed = maxGiftedSeconds;
+      }
+
+      let totalSecondsUsed = org.crawlExecSeconds?.[mY] || 0;
+      const totalMaxQuota =
+        maxMonthlySeconds + maxExtraSeconds + maxGiftedSeconds;
+      if (totalSecondsUsed > totalMaxQuota) {
+        totalSecondsUsed = totalMaxQuota;
+      }
+
+      const item: Partial<GridItem<Field>> = {
+        [Field.Month]: mY,
+        [Field.ElapsedTime]: crawlTime || 0,
+        [Field.ExecutionTime]: totalSecondsUsed,
+        [Field.BillableExecutionTime]: monthlySecondsUsed,
+        [Field.RolloverExecutionTime]: extraSecondsUsed,
+        [Field.GiftedExecutionTime]: giftedSecondsUsed,
+      };
+
+      items.unshift(item);
+    });
+
     return html`
-      <btrix-data-table
-        .columns=${usageTableCols}
-        .rows=${rows}
-      ></btrix-data-table>
+      <btrix-data-grid
+        .columns=${cols}
+        .items=${items}
+        stickyHeader
+      ></btrix-data-grid>
     `;
   }
+
+  private readonly renderSecondsForField =
+    (field: Field) =>
+    ({ item }: { item: GridItem<Field> }) => html`
+      ${item[field] ? humanizeExecutionSeconds(+item[field]) : noData}
+    `;
 }
diff --git a/frontend/src/stories/components/DataGrid.stories.ts b/frontend/src/stories/components/DataGrid.stories.ts
new file mode 100644
index 0000000000..e1884b2ed6
--- /dev/null
+++ b/frontend/src/stories/components/DataGrid.stories.ts
@@ -0,0 +1,264 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html } from "lit";
+
+import { defaultArgs, renderComponent, type RenderProps } from "./DataGrid";
+import {
+  dataGridDecorator,
+  formControlName,
+} from "./decorators/dataGridDecorator";
+
+import { DataGridRowsController } from "@/components/ui/data-grid/controllers/rows";
+import { renderRows } from "@/components/ui/data-grid/renderRows";
+import { GridColumnType } from "@/components/ui/data-grid/types";
+
+const meta = {
+  title: "Components/Data Grid",
+  component: "btrix-data-grid",
+  subcomponents: {
+    DataGridRow: "btrix-data-grid-row",
+    DataGridCell: "btrix-data-grid-cell",
+  },
+  tags: ["autodocs"],
+  render: renderComponent,
+  argTypes: {},
+  args: {},
+} satisfies Meta<RenderProps>;
+
+export default meta;
+type Story = StoryObj<RenderProps>;
+
+/**
+ * In its most basic configuration, the only required fields
+ * are a list of items, and a list of columns that define which
+ * key-value pairs of an item should be displayed.
+ */
+export const Basic: Story = {
+  args: {},
+};
+
+/**
+ * The table header can stick to the top of the containing element.
+ */
+export const StickyHeader: Story = {
+  args: {
+    stickyHeader: true,
+  },
+};
+
+/**
+ * Table header cells can convey additional information in a tooltip.
+ */
+export const HeaderTooltip: Story = {
+  args: {
+    columns: [
+      {
+        ...defaultArgs.columns[0],
+        description: "This is a description of 'A'",
+      },
+      {
+        ...defaultArgs.columns[1],
+        description: "This is a description of 'B'",
+      },
+      ...defaultArgs.columns.slice(2),
+    ],
+  },
+};
+
+const colWidths = ["200px", "10em", "min-content", "auto", "1fr"];
+
+/**
+ * Columns can have specified widths set to any `grid-template-columns`
+ * [track list value](https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-columns#syntax).
+ */
+export const ColumnWidths: Story = {
+  args: {
+    columns: defaultArgs.columns.map((col, i) => ({
+      ...col,
+      width: colWidths[i],
+    })),
+  },
+};
+
+/**
+ * Rows can be removed.
+ */
+export const RemoveRows: Story = {
+  args: {
+    removeRows: true,
+  },
+};
+
+/**
+ * Rows can also be added, with an optional default item for new rows.
+ */
+export const AddRows: Story = {
+  args: {
+    addRows: true,
+    defaultItem: {
+      a: "A",
+      b: "--",
+      c: "--",
+      d: "--",
+      e: "--",
+    },
+  },
+};
+
+/**
+ * The number of rows being added can be configurable.
+ */
+export const AddRowsInput: Story = {
+  name: "Add more than one row",
+  args: {
+    addRows: true,
+    addRowsInputValue: 5,
+    defaultItem: {
+      a: "A",
+      b: "--",
+      c: "--",
+      d: "--",
+      e: "--",
+    },
+  },
+};
+
+/**
+ * Cells can be editable.
+ */
+export const EditCells: Story = {
+  args: {
+    editCells: true,
+    columns: defaultArgs.columns.map((col) => ({
+      ...col,
+      width: "1fr",
+    })),
+    items: defaultArgs.items.map((item) => ({
+      ...item,
+      a: `${(item as Record<string, string>).a} (not editable)`,
+    })),
+  },
+};
+
+/**
+ * The data grid can become a group of form controls, complete with validation.
+ *
+ * The caveat is that in order for the outer form to recognize the rows as form
+ * controls, row components must be slotted into the `rows` slot of the grid
+ * component. Each row must have the same `name` attribute in order to be
+ * serialized as the same form control.
+ *
+ * A few helpers are included to make managing rows easier:
+ * - `DataGridController` to add and remove slotted rows
+ * - `renderRows` to render `<btrix-data-grid-row>`
+ * - `serializeDeep` to parse form values
+ *
+ * Open console logs to view the form value submitted in this example.
+ */
+export const FormControl: Story = {
+  args: {
+    columns: [
+      {
+        field: "url",
+        label: "URL",
+        editable: true,
+        inputType: GridColumnType.URL,
+        inputPlaceholder: "Enter URL",
+        required: true,
+      },
+      {
+        field: "title",
+        label: "Title",
+        editable: true,
+        inputPlaceholder: "Enter page title",
+        required: true,
+      },
+      {
+        field: "selector",
+        label: "Heading Selector",
+        editable: true,
+        inputPlaceholder: "h1",
+        renderEditCell({ item }) {
+          return html`
+            <btrix-syntax-input
+              name="selector"
+              class="flex-1 [--sl-input-border-radius-medium:0] [--sl-input-border-color:transparent]"
+              value=${item.selector || ""}
+              language="css"
+            ></btrix-syntax-input>
+          `;
+        },
+      },
+      {
+        field: "count",
+        label: "Crawl Count",
+        editable: true,
+        inputType: GridColumnType.Number,
+        inputPlaceholder: "Enter count",
+      },
+      {
+        field: "status",
+        label: "Status",
+        editable: true,
+        inputType: GridColumnType.Select,
+        selectOptions: [
+          {
+            value: "Pending",
+          },
+          {
+            value: "Approved",
+          },
+        ],
+      },
+    ],
+    items: [
+      {
+        title: "Title 1",
+        selector: "h1",
+        count: 2,
+        url: "https://example.com/page-1",
+        status: "Approved",
+      },
+      {
+        title: "Title 2",
+        selector: "div.heading",
+        count: 1,
+        url: "https://example.com/page-2",
+        status: "Pending",
+      },
+    ],
+  },
+  decorators: [dataGridDecorator],
+  render: (args, context) => {
+    const rows =
+      context.rowsController instanceof DataGridRowsController
+        ? context.rowsController.rows
+        : new Map();
+
+    return html`
+      <btrix-data-grid
+        .columns=${args.columns}
+        .rowsController=${
+          // `rowsController` context is added by `dataGridDecorator`
+          context.rowsController
+        }
+        formControlLabel="Page QA Table"
+        stickyHeader
+        addRows
+        removeRows
+        editCells
+      >
+        ${renderRows(
+          rows,
+          ({ id, item }) => html`
+            <btrix-data-grid-row
+              slot="rows"
+              name="${formControlName}"
+              key=${id}
+              .item=${item}
+            ></btrix-data-grid-row>
+          `,
+        )}
+      </btrix-data-grid>
+    `;
+  },
+};
diff --git a/frontend/src/stories/components/DataGrid.ts b/frontend/src/stories/components/DataGrid.ts
new file mode 100644
index 0000000000..3f07d21166
--- /dev/null
+++ b/frontend/src/stories/components/DataGrid.ts
@@ -0,0 +1,57 @@
+import { html } from "lit";
+import { ifDefined } from "lit/directives/if-defined.js";
+import { nanoid } from "nanoid";
+
+import type { DataGrid } from "@/components/ui/data-grid/data-grid";
+
+import "@/components/ui/data-grid";
+
+export type RenderProps = Pick<DataGrid, keyof DataGrid>;
+
+const columns = "abcde".split("").map((field, i) => ({
+  field,
+  label: field.toUpperCase(),
+  editable: i > 0,
+})) satisfies RenderProps["columns"];
+const items = Array.from({ length: 5 }).map((_, i) => ({
+  ...columns.reduce(
+    (obj, { field, label }) => ({
+      ...obj,
+      [field]: `${label}${i + 1}`,
+    }),
+    {},
+  ),
+  id: nanoid(),
+})) satisfies RenderProps["items"];
+
+export const defaultArgs = { columns, items } satisfies Pick<
+  RenderProps,
+  "columns" | "items"
+>;
+
+export const renderComponent = ({
+  columns,
+  items,
+  formControlLabel,
+  stickyHeader,
+  addRows,
+  addRowsInputValue,
+  removeRows,
+  editCells,
+  defaultItem,
+}: Partial<RenderProps>) => {
+  return html`
+    <btrix-data-grid
+      .columns=${columns || defaultArgs.columns}
+      .items=${items || defaultArgs.items}
+      .defaultItem=${defaultItem}
+      formControlLabel=${ifDefined(formControlLabel)}
+      ?stickyHeader=${stickyHeader}
+      ?addRows=${addRows}
+      addRowsInputValue=${ifDefined(addRowsInputValue)}
+      ?removeRows=${removeRows}
+      ?editCells=${editCells}
+    >
+    </btrix-data-grid>
+  `;
+};
diff --git a/frontend/src/stories/components/decorators/DataGridDecorator.ts b/frontend/src/stories/components/decorators/DataGridDecorator.ts
new file mode 100644
index 0000000000..8eade25023
--- /dev/null
+++ b/frontend/src/stories/components/decorators/DataGridDecorator.ts
@@ -0,0 +1,61 @@
+import type { StoryContext, StoryFn } from "@storybook/web-components";
+import { html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+import { TailwindElement } from "@/classes/TailwindElement";
+import { DataGridRowsController } from "@/components/ui/data-grid/controllers/rows";
+import type { GridItem } from "@/components/ui/data-grid/types";
+import { serializeDeep } from "@/utils/form";
+
+type DataGridStoryContext = { rowsController: DataGridRowsController };
+
+export const formControlName = "storybook--data-grid-form-example";
+
+@customElement("btrix-storybook-data-grid-form")
+export class StorybookDataGridForm extends TailwindElement {
+  readonly #rowsController = new DataGridRowsController(this);
+
+  public renderStory!: (context: DataGridStoryContext) => ReturnType<StoryFn>;
+
+  @property({ type: Array })
+  items?: GridItem[] = [];
+
+  render() {
+    const onSubmit = (e: SubmitEvent) => {
+      e.preventDefault();
+
+      const form = e.target as HTMLFormElement;
+      const value = serializeDeep(form, { parseKeys: [formControlName] });
+
+      console.log("form value:", value);
+    };
+
+    return html`
+      <form @submit=${onSubmit}>
+        ${this.renderStory({
+          rowsController: this.#rowsController,
+        })}
+        <footer class="mt-4">
+          <sl-button type="reset">Reset</sl-button>
+          <sl-button type="submit" variant="primary">Submit</sl-button>
+        </footer>
+      </form>
+    `;
+  }
+}
+
+export function dataGridDecorator(story: StoryFn, context: StoryContext) {
+  return html`<btrix-storybook-data-grid-form
+    .items=${context.args.items as GridItem[]}
+    .renderStory=${(ctx: DataGridStoryContext) => {
+      return story(
+        {
+          ...context.args,
+          ...ctx,
+        },
+        context,
+      );
+    }}
+  >
+  </btrix-storybook-data-grid-form>`;
+}
diff --git a/frontend/src/stories/components/decorators/dataGridDecorator.ts b/frontend/src/stories/components/decorators/dataGridDecorator.ts
new file mode 100644
index 0000000000..8eade25023
--- /dev/null
+++ b/frontend/src/stories/components/decorators/dataGridDecorator.ts
@@ -0,0 +1,61 @@
+import type { StoryContext, StoryFn } from "@storybook/web-components";
+import { html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+import { TailwindElement } from "@/classes/TailwindElement";
+import { DataGridRowsController } from "@/components/ui/data-grid/controllers/rows";
+import type { GridItem } from "@/components/ui/data-grid/types";
+import { serializeDeep } from "@/utils/form";
+
+type DataGridStoryContext = { rowsController: DataGridRowsController };
+
+export const formControlName = "storybook--data-grid-form-example";
+
+@customElement("btrix-storybook-data-grid-form")
+export class StorybookDataGridForm extends TailwindElement {
+  readonly #rowsController = new DataGridRowsController(this);
+
+  public renderStory!: (context: DataGridStoryContext) => ReturnType<StoryFn>;
+
+  @property({ type: Array })
+  items?: GridItem[] = [];
+
+  render() {
+    const onSubmit = (e: SubmitEvent) => {
+      e.preventDefault();
+
+      const form = e.target as HTMLFormElement;
+      const value = serializeDeep(form, { parseKeys: [formControlName] });
+
+      console.log("form value:", value);
+    };
+
+    return html`
+      <form @submit=${onSubmit}>
+        ${this.renderStory({
+          rowsController: this.#rowsController,
+        })}
+        <footer class="mt-4">
+          <sl-button type="reset">Reset</sl-button>
+          <sl-button type="submit" variant="primary">Submit</sl-button>
+        </footer>
+      </form>
+    `;
+  }
+}
+
+export function dataGridDecorator(story: StoryFn, context: StoryContext) {
+  return html`<btrix-storybook-data-grid-form
+    .items=${context.args.items as GridItem[]}
+    .renderStory=${(ctx: DataGridStoryContext) => {
+      return story(
+        {
+          ...context.args,
+          ...ctx,
+        },
+        context,
+      );
+    }}
+  >
+  </btrix-storybook-data-grid-form>`;
+}
diff --git a/frontend/src/stories/decorators/orgDecorator.ts b/frontend/src/stories/decorators/orgDecorator.ts
new file mode 100644
index 0000000000..4ec4a7294e
--- /dev/null
+++ b/frontend/src/stories/decorators/orgDecorator.ts
@@ -0,0 +1,57 @@
+import type { StoryContext, StoryFn } from "@storybook/web-components";
+import { html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import mapValues from "lodash/fp/mapValues";
+
+import orgMock from "@/__mocks__/api/orgs/[id]";
+import { AppStateService } from "@/utils/state";
+
+const { users, usage, quotas, ...org } = orgMock;
+
+export type StorybookOrgProps = {
+  orgUsers?: boolean;
+  orgUsage?: boolean;
+  orgQuotas?: boolean;
+};
+
+@customElement("btrix-storybook-org")
+export class StorybookOrg extends LitElement {
+  @property({ type: Boolean })
+  users?: boolean;
+
+  @property({ type: Boolean })
+  usage?: boolean;
+
+  @property({ type: Boolean })
+  quotas?: boolean;
+
+  connectedCallback(): void {
+    super.connectedCallback();
+
+    AppStateService.updateOrg({
+      ...org,
+      users: this.users ? users : {},
+      usage: this.usage ? usage : {},
+      quotas: this.quotas
+        ? quotas
+        : (mapValues(() => 0, quotas) as typeof quotas),
+    });
+  }
+
+  render() {
+    return html`<slot></slot>`;
+  }
+}
+
+export function orgDecorator(story: StoryFn, context: StoryContext) {
+  const { args } = context;
+  const { orgUsers, orgUsage, orgQuotas } = args as StorybookOrgProps;
+
+  return html`<btrix-storybook-org
+    ?users=${orgUsers}
+    ?usage=${orgUsage}
+    ?quotas=${orgQuotas}
+  >
+    ${story(args, context)}
+  </btrix-storybook-org>`;
+}
diff --git a/frontend/src/stories/features/archived-items/CrawlLogs.stories.ts b/frontend/src/stories/features/archived-items/CrawlLogs.stories.ts
index 05f0826c57..ad1221f203 100644
--- a/frontend/src/stories/features/archived-items/CrawlLogs.stories.ts
+++ b/frontend/src/stories/features/archived-items/CrawlLogs.stories.ts
@@ -1,7 +1,7 @@
 import type { Meta, StoryObj } from "@storybook/web-components";
 import { html } from "lit";
 
-import { argTypes } from "../excludeBtrixElementProperties";
+import { argTypes } from "../excludeContainerProperties";
 
 import type { CrawlLogTable } from "@/features/archived-items/crawl-log-table";
 import { CrawlLogContext, CrawlLogLevel } from "@/types/crawler";
diff --git a/frontend/src/stories/features/crawl-workflows/CustomBehaviorsTable.stories.ts b/frontend/src/stories/features/crawl-workflows/CustomBehaviorsTable.stories.ts
index 4ab22a6840..84d64ec3db 100644
--- a/frontend/src/stories/features/crawl-workflows/CustomBehaviorsTable.stories.ts
+++ b/frontend/src/stories/features/crawl-workflows/CustomBehaviorsTable.stories.ts
@@ -1,7 +1,7 @@
 import type { Meta, StoryObj } from "@storybook/web-components";
 import { html } from "lit";
 
-import { argTypes } from "../excludeBtrixElementProperties";
+import { argTypes } from "../excludeContainerProperties";
 
 import type { CustomBehaviorsTable } from "@/features/crawl-workflows/custom-behaviors-table";
 
diff --git a/frontend/src/stories/features/crawl-workflows/LinkSelectorTable.stories.ts b/frontend/src/stories/features/crawl-workflows/LinkSelectorTable.stories.ts
index 4e43638ae3..3a8114da33 100644
--- a/frontend/src/stories/features/crawl-workflows/LinkSelectorTable.stories.ts
+++ b/frontend/src/stories/features/crawl-workflows/LinkSelectorTable.stories.ts
@@ -1,7 +1,7 @@
 import type { Meta, StoryObj } from "@storybook/web-components";
 import { html } from "lit";
 
-import { argTypes } from "../excludeBtrixElementProperties";
+import { argTypes } from "../excludeContainerProperties";
 
 import type { LinkSelectorTable } from "@/features/crawl-workflows/link-selector-table";
 
diff --git a/frontend/src/stories/features/crawl-workflows/QueueExclusionForm.stories.ts b/frontend/src/stories/features/crawl-workflows/QueueExclusionForm.stories.ts
index 4d6f1bcf4d..58b8ecb5a2 100644
--- a/frontend/src/stories/features/crawl-workflows/QueueExclusionForm.stories.ts
+++ b/frontend/src/stories/features/crawl-workflows/QueueExclusionForm.stories.ts
@@ -1,7 +1,7 @@
 import type { Meta, StoryObj } from "@storybook/web-components";
 import { html } from "lit";
 
-import { argTypes } from "../excludeBtrixElementProperties";
+import { argTypes } from "../excludeContainerProperties";
 
 import type { QueueExclusionForm } from "@/features/crawl-workflows/queue-exclusion-form";
 
diff --git a/frontend/src/stories/features/crawl-workflows/QueueExclusionTable.stories.ts b/frontend/src/stories/features/crawl-workflows/QueueExclusionTable.stories.ts
index cae65f77ac..a903050632 100644
--- a/frontend/src/stories/features/crawl-workflows/QueueExclusionTable.stories.ts
+++ b/frontend/src/stories/features/crawl-workflows/QueueExclusionTable.stories.ts
@@ -1,7 +1,7 @@
 import type { Meta, StoryObj } from "@storybook/web-components";
 import { html } from "lit";
 
-import { argTypes } from "../excludeBtrixElementProperties";
+import { argTypes } from "../excludeContainerProperties";
 
 import type { QueueExclusionTable } from "@/features/crawl-workflows/queue-exclusion-table";
 import { tw } from "@/utils/tailwind";
diff --git a/frontend/src/stories/features/excludeBtrixElementProperties.ts b/frontend/src/stories/features/excludeContainerProperties.ts
similarity index 60%
rename from frontend/src/stories/features/excludeBtrixElementProperties.ts
rename to frontend/src/stories/features/excludeContainerProperties.ts
index 9fe37dd5a3..5e3a430bf0 100644
--- a/frontend/src/stories/features/excludeBtrixElementProperties.ts
+++ b/frontend/src/stories/features/excludeContainerProperties.ts
@@ -1,6 +1,8 @@
 /**
- * Exclude `BtrixElement` properties from story controls
+ * Exclude `BtrixElement` and `StorybookOrg` properties from story controls
  */
+import { StorybookOrg } from "../decorators/orgDecorator";
+
 import { BtrixElement } from "@/classes/BtrixElement";
 
 const controlOpts = { table: { disable: true } };
@@ -17,4 +19,10 @@ Object.getOwnPropertyNames(BtrixElement.prototype).forEach((prop) => {
   argTypes[prop] = controlOpts;
 });
 
+Object.getOwnPropertyNames(StorybookOrg.prototype).forEach((prop) => {
+  if (prop === "constructor") return;
+
+  argTypes[prop] = controlOpts;
+});
+
 export { argTypes };
diff --git a/frontend/src/stories/features/org/UsageHistoryTable.stories.ts b/frontend/src/stories/features/org/UsageHistoryTable.stories.ts
new file mode 100644
index 0000000000..a61e8e45a1
--- /dev/null
+++ b/frontend/src/stories/features/org/UsageHistoryTable.stories.ts
@@ -0,0 +1,46 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html } from "lit";
+import type { DecoratorFunction } from "storybook/internal/types";
+
+import { argTypes } from "../excludeContainerProperties";
+
+import type { UsageHistoryTable } from "@/features/org/usage-history-table";
+import {
+  orgDecorator,
+  type StorybookOrgProps,
+} from "@/stories/decorators/orgDecorator";
+
+import "@/features/org/usage-history-table";
+
+type RenderProps = UsageHistoryTable & StorybookOrgProps;
+
+const meta = {
+  title: "Features/Usage History Table",
+  component: "btrix-usage-history-table",
+  tags: ["autodocs"],
+  decorators: [orgDecorator as DecoratorFunction],
+  render: () => html` <btrix-usage-history-table></btrix-usage-history-table> `,
+  argTypes: {
+    ...argTypes,
+  },
+  args: {},
+} satisfies Meta<RenderProps>;
+
+export default meta;
+type Story = StoryObj<RenderProps>;
+
+/**
+ * @FIXME The "Docs" view will currently always show an empty usage history table
+ * since usage is configured through global app state.
+ *
+ * Navigate to "With Usage" to see a working example.
+ */
+export const WithUsage: Story = {
+  args: {
+    orgUsage: true,
+  },
+};
+
+export const WithoutUsage: Story = {
+  args: {},
+};
diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css
index 3bf6766863..d7520b2a7e 100644
--- a/frontend/src/theme.stylesheet.css
+++ b/frontend/src/theme.stylesheet.css
@@ -61,6 +61,10 @@
     --sl-font-weight-medium: 500;
     --sl-font-weight-semibold: 600;
 
+    /* Focus rings */
+    --sl-focus-ring-color: var(--sl-color-primary-200);
+    --sl-focus-ring-width: 2px;
+
     /*
      * Forms
      */
@@ -101,6 +105,11 @@
   body {
     font-size: var(--sl-font-size-medium);
   }
+
+  :focus-visible {
+    outline: var(--sl-focus-ring);
+    outline-offset: var(--sl-focus-ring-offset);
+  }
 }
 
 @layer components {
diff --git a/frontend/src/types/org.ts b/frontend/src/types/org.ts
index e5b5e4d08a..3a50aaec71 100644
--- a/frontend/src/types/org.ts
+++ b/frontend/src/types/org.ts
@@ -90,7 +90,7 @@ export const orgDataSchema = z.object({
     .optional(),
   readOnly: z.boolean().nullable(),
   readOnlyReason: z.union([orgReadOnlyReasonSchema, z.string()]).nullable(),
-  readOnlyOnCancel: z.boolean(),
+  readOnlyOnCancel: z.boolean().optional(),
   subscription: subscriptionSchema.nullable(),
   crawlingDefaults: crawlingDefaultsSchema.nullable(),
   allowSharedProxies: z.boolean(),
diff --git a/frontend/src/utils/form.ts b/frontend/src/utils/form.ts
index ad7edfec8e..a525dad30f 100644
--- a/frontend/src/utils/form.ts
+++ b/frontend/src/utils/form.ts
@@ -1,6 +1,9 @@
 import { msg, str } from "@lit/localize";
 import type { SlInput, SlTextarea } from "@shoelace-style/shoelace";
-import { getFormControls } from "@shoelace-style/shoelace/dist/utilities/form.js";
+import {
+  getFormControls,
+  serialize,
+} from "@shoelace-style/shoelace/dist/utilities/form.js";
 import type { LitElement } from "lit";
 
 import localize from "./localize";
@@ -85,3 +88,30 @@ export function formValidator(el: LitElement) {
     );
   };
 }
+
+/**
+ * Serialize forms with stringified JSON data, likely
+ * when used with `<btrix-data-grid>`.
+ */
+export function serializeDeep(
+  form: HTMLFormElement,
+  opts?: { parseKeys: string[] },
+) {
+  const values = serialize(form);
+
+  if (opts) {
+    opts.parseKeys.forEach((key) => {
+      const val = values[key];
+
+      if (typeof val === "string") {
+        values[key] = JSON.parse(val);
+      } else if (Array.isArray(val)) {
+        values[key] = val.map<unknown>((v) =>
+          typeof v === "string" ? JSON.parse(v) : v,
+        );
+      }
+    });
+  }
+
+  return values;
+}
diff --git a/frontend/src/utils/pluralize.ts b/frontend/src/utils/pluralize.ts
index ebbb592e87..5fac6a9a6b 100644
--- a/frontend/src/utils/pluralize.ts
+++ b/frontend/src/utils/pluralize.ts
@@ -143,6 +143,32 @@ const plurals = {
       id: "URLs.plural.other",
     }),
   },
+  rows: {
+    zero: msg("rows", {
+      desc: 'plural form of "rows" for zero rows',
+      id: "rows.plural.zero",
+    }),
+    one: msg("row", {
+      desc: 'singular form for "row"',
+      id: "rows.plural.one",
+    }),
+    two: msg("rows", {
+      desc: 'plural form of "rows" for two rows',
+      id: "rows.plural.two",
+    }),
+    few: msg("rows", {
+      desc: 'plural form of "rows" for few rows',
+      id: "rows.plural.few",
+    }),
+    many: msg("rows", {
+      desc: 'plural form of "rows" for many rows',
+      id: "rows.plural.many",
+    }),
+    other: msg("rows", {
+      desc: 'plural form of "rows" for multiple/other rows',
+      id: "rows.plural.other",
+    }),
+  },
 };
 
 export const pluralOf = (word: keyof typeof plurals, count: number) => {
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index c4390f225f..5b56925be8 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -10169,6 +10169,11 @@ swc-loader@^0.2.6:
   dependencies:
     "@swc/counter" "^0.1.3"
 
+tabbable@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
+  integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
+
 table-layout@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04"