Repository: marimo-team/marimo

Issue:
Propogate column information when cell selected in `mo.ui.Table`
### Description

In the `mo.ui.Table` component (https://docs.marimo.io/api/inputs/table/) it's possible to select one or more rows when clicking on the table. I propose adding more selection modes to make it possible to select a cell and get both the row and column information associated with that cell.

In our use case we have some fairly wide dataframes and would like our users to be able to trigger some charts on more than one dimension by just clicking one cell.



### Suggested solution

In the `mo.ui.Table` component we have the argument:

```
    selection: Optional[
        Literal["single", "multi"]
    ] = "multi",
```

At the moment this allows two modes:

- `single`: only one row can be selected
- `multi`: many rows can be selected

I propose adding a new option to this:

- `cell`: when a cell is clicked then both the row and column information is sent to the defined `on_change` callback.

At the moment, the `on_change` callback is called with a list of dicts of all the information in the selected rows. I'm not sure what is a clean way to do this, but two suggestions come to mind:

- Keep this first argument the same but add an additional argument which contains the row_idx and the column name which was selected.
- Add the row_idx and column name into the existing argument e.g.:

```
[{'first_name': 'Michael', 'last_name': 'Scott'}]
```

Would become:

```
[{'first_name': 'Michael', 'last_name': 'Scott', 'row_idx': 1, column_selected: 'first_name'}]
```


### Alternative

_No response_

### Additional context

_No response_

Discussion:
@mscolnick: Makes sense, I think we can support this. I would see the options as:

```
single -> implies single-row (for back-compat)
multi -> implies multi-row (for back-compat)

single-cell
multi-cell

# maybe layer
single-column
multi-column
```

In call cases, the response will likely be the same shape passed in. So if a dataframe is pass in, the `on_change` and `.value` will be a dataframe. if its a list/dict, itll be a list/dict.@jp-gr: Thanks for the quick reply!

I agree in terms of the data returned. Our main use case is actually dataframes, so the user would necessarily get the row index in the dataframe case. However in the list/dict case how would you propose giving the user enough metadata to make it useful?

For our dataframe use case we have data like:
```
pd.DataFrame({
        "day": ["2020-01-01", "2020-01-02"],
        "id": [1, 1],
        "metric1": [1.0, 2.0],
        "metric2": [2.0, 4.0],
    })
```  

If I had set this up as a list/dict instead of a dataframe and the user clicked the `4.0` value, would you propose passing in something like the following?
```
{'value': 4.0, 'row_idx': 2, column_selected: 'metric2'}
```@nojaf: Hello @jp-gr 

Just wanted to let you know that this is available in the [0.11.17 release](https://github.com/marimo-team/marimo/releases/tag/0.11.17).

![Image](https://github.com/user-attachments/assets/b4a07319-d900-4d21-ad35-c67f7036982c)

Please let me know if there is anything I can do to further improve this!

//cc @dave-gantenbein

Solution patch:
diff --git a/frontend/src/components/data-table/TableActions.tsx b/frontend/src/components/data-table/TableActions.tsx
index 0c16f729..f20b183a 100644
--- a/frontend/src/components/data-table/TableActions.tsx
+++ b/frontend/src/components/data-table/TableActions.tsx
@@ -8,6 +8,7 @@ import { SearchIcon } from "lucide-react";
 import { DataTablePagination } from "./pagination";
 import { DownloadAs, type DownloadActionProps } from "./download-actions";
 import type { Table, RowSelectionState } from "@tanstack/react-table";
+import type { DataTableSelection } from "./types";
 import type { GetRowIds } from "@/plugins/impl/DataTablePlugin";
 import { toast } from "../ui/use-toast";
 
@@ -18,7 +19,7 @@ interface TableActionsProps<TData> {
   setIsSearchEnabled: React.Dispatch<React.SetStateAction<boolean>>;
   pagination: boolean;
   totalColumns: number;
-  selection?: "single" | "multi" | null;
+  selection?: DataTableSelection;
   onRowSelectionChange?: (value: RowSelectionState) => void;
   table: Table<TData>;
   downloadAs?: DownloadActionProps["downloadAs"];
diff --git a/frontend/src/components/data-table/cell-selection/feature.ts b/frontend/src/components/data-table/cell-selection/feature.ts
new file mode 100644
index 00000000..abebfc25
--- /dev/null
+++ b/frontend/src/components/data-table/cell-selection/feature.ts
@@ -0,0 +1,127 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+"use no memo";
+
+import {
+  type Table,
+  type Cell,
+  type TableFeature,
+  type InitialTableState,
+  type RowData,
+  makeStateUpdater,
+  type Column,
+  type Row,
+} from "@tanstack/react-table";
+
+import { Functions } from "@/utils/functions";
+
+import type {
+  CellSelectionOptions,
+  CellSelectionState,
+  CellSelectionTableState,
+} from "./types";
+import { INDEX_COLUMN_NAME } from "../types";
+
+function getRowId<TData>(row: Row<TData>): string {
+  if (row && typeof row === "object" && INDEX_COLUMN_NAME in row) {
+    return String(row[INDEX_COLUMN_NAME]);
+  }
+  return row.id;
+}
+
+export const CellSelectionFeature: TableFeature = {
+  getInitialState: (state?: InitialTableState): CellSelectionTableState => {
+    return {
+      ...state,
+      cellSelection: [] as CellSelectionState,
+    };
+  },
+
+  getDefaultOptions: <TData extends RowData>(
+    table: Table<TData>,
+  ): CellSelectionOptions => ({
+    onCellSelectionChange: makeStateUpdater("cellSelection", table),
+    enableCellSelection: false,
+  }),
+
+  createTable: <TData>(table: Table<TData>): void => {
+    table.setCellSelection = (updater) => {
+      // TODO: can we access?
+      // table._getRowId
+      // check if pagination is active
+
+      table.setState((tableState) => ({
+        ...tableState,
+        cellSelection: Functions.asUpdater(updater)(tableState.cellSelection),
+      }));
+      table.options.onCellSelectionChange?.(updater);
+    };
+
+    table.resetCellSelection = (defaultValue) => {
+      if (table.setCellSelection) {
+        table.setCellSelection(() => defaultValue ?? []);
+      }
+    };
+  },
+
+  createCell: <TData extends RowData>(
+    cell: Cell<TData, unknown>,
+    column: Column<TData>,
+    row: Row<TData>,
+    table: Table<TData>,
+  ) => {
+    // This could be a performance bottleneck if we have a lot of cells.
+    // (for each cell, for each selected cell), which is O(n^2)
+    // Perhaps consider using datastructure mentioned in https://github.com/marimo-team/marimo/pull/3725/files#r1974362428
+    cell.getIsSelected = () => {
+      const state: CellSelectionState = table.getState().cellSelection ?? [];
+      return state.some(
+        (item) => item.rowId === getRowId(row) && item.columnName === column.id,
+      );
+    };
+
+    cell.toggleSelected = (value?: boolean) => {
+      if (!table.setCellSelection) {
+        return;
+      }
+
+      const columnName = column.id;
+
+      const currentIsSelected = cell.getIsSelected?.() || false;
+      const nextIsSelected = value === undefined ? !currentIsSelected : value;
+      const rowId = getRowId(row);
+
+      if (nextIsSelected && !currentIsSelected) {
+        // Add cell to selection
+        if (table.options.enableMultiCellSelection) {
+          table.setCellSelection((selectedCells) => [
+            {
+              rowId,
+              columnName: columnName,
+            },
+            ...selectedCells,
+          ]);
+        } else {
+          // This cell becomes the single selected cell
+          table.setCellSelection((_) => [
+            {
+              rowId: rowId,
+              columnName: columnName,
+            },
+          ]);
+        }
+      } else if (currentIsSelected && !nextIsSelected) {
+        // Deselect cell from selection
+        if (table.options.enableMultiCellSelection) {
+          table.setCellSelection((selectedCells) =>
+            selectedCells.filter(
+              (c) => c.rowId !== rowId && c.columnName !== columnName,
+            ),
+          );
+        } else {
+          // Clear the selection
+          table.setCellSelection((_) => []);
+        }
+      }
+    };
+  },
+};
diff --git a/frontend/src/components/data-table/cell-selection/types.ts b/frontend/src/components/data-table/cell-selection/types.ts
new file mode 100644
index 00000000..40d448f2
--- /dev/null
+++ b/frontend/src/components/data-table/cell-selection/types.ts
@@ -0,0 +1,49 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+/* eslint-disable @typescript-eslint/no-empty-interface */
+import type { OnChangeFn, RowData, Updater } from "@tanstack/react-table";
+
+export interface CellSelectionItem {
+  rowId: string;
+  columnName: string;
+}
+export type CellSelectionState = CellSelectionItem[];
+export interface CellSelectionTableState {
+  cellSelection: CellSelectionState;
+}
+
+export interface CellSelectionOptions {
+  enableCellSelection?: boolean;
+  enableMultiCellSelection?: boolean;
+  onCellSelectionChange?: OnChangeFn<CellSelectionState>; // | CellSelectionItem ???
+}
+
+export interface CellSelectionCell {
+  /**
+   * Returns whether or not the cell is selected.
+   */
+  getIsSelected?: () => boolean;
+
+  /**
+   * Selects/deselects the cell.
+   */
+  toggleSelected?: (value?: boolean) => void;
+}
+
+// These are additional properties add to the table instance in createTable
+export interface CellSelectionInstance<TData extends RowData> {
+  setCellSelection?: (updater: Updater<CellSelectionState>) => void;
+
+  resetCellSelection?(defaultValue?: CellSelectionState): void;
+}
+
+// Use declaration merging to add our new feature APIs
+declare module "@tanstack/react-table" {
+  interface TableState extends CellSelectionTableState {}
+
+  interface TableOptionsResolved<TData extends RowData>
+    extends CellSelectionOptions {}
+
+  interface Table<TData extends RowData> extends CellSelectionInstance<TData> {}
+
+  interface Cell<TData extends RowData, TValue> extends CellSelectionCell {}
+}
diff --git a/frontend/src/components/data-table/columns.tsx b/frontend/src/components/data-table/columns.tsx
index f924669b..57ccc7d8 100644
--- a/frontend/src/components/data-table/columns.tsx
+++ b/frontend/src/components/data-table/columns.tsx
@@ -11,7 +11,11 @@ import { isMimeValue, MimeCell } from "./mime-cell";
 import type { DataType } from "@/core/kernel/messages";
 import { TableColumnSummary } from "./column-summary";
 import type { FilterType } from "./filters";
-import { INDEX_COLUMN_NAME, type FieldTypesWithExternalType } from "./types";
+import {
+  type DataTableSelection,
+  INDEX_COLUMN_NAME,
+  type FieldTypesWithExternalType,
+} from "./types";
 import { UrlDetector } from "./url-detector";
 import { cn } from "@/utils/cn";
 import { uniformSample } from "./uniformSample";
@@ -89,7 +93,7 @@ export function generateColumns<T>({
   showDataTypes,
 }: {
   rowHeaders: string[];
-  selection: "single" | "multi" | null;
+  selection: DataTableSelection;
   fieldTypes: FieldTypesWithExternalType;
   textJustifyColumns?: Record<string, "left" | "center" | "right">;
   wrappedColumns?: string[];
@@ -167,7 +171,7 @@ export function generateColumns<T>({
         );
       },
 
-      cell: ({ column, renderValue, getValue }) => {
+      cell: ({ column, renderValue, getValue, cell }) => {
         // Row headers are bold
         if (rowHeadersSet.has(key)) {
           return <b>{String(renderValue())}</b>;
@@ -177,10 +181,31 @@ export function generateColumns<T>({
         const justify = textJustifyColumns?.[key];
         const wrapped = wrappedColumns?.includes(key);
 
+        function selectCell() {
+          if (selection !== "single-cell" && selection !== "multi-cell") {
+            return;
+          }
+
+          cell.toggleSelected?.();
+        }
+
+        const isCellSelected = cell?.getIsSelected?.() || false;
+        const canSelectCell =
+          (selection === "single-cell" || selection === "multi-cell") &&
+          !isCellSelected;
+
         const format = column.getColumnFormatting?.();
         if (format) {
           return (
-            <div className={getCellStyleClass(justify, wrapped)}>
+            <div
+              onClick={selectCell}
+              className={getCellStyleClass(
+                justify,
+                wrapped,
+                canSelectCell,
+                isCellSelected,
+              )}
+            >
               {column.applyColumnFormatting(value)}
             </div>
           );
@@ -189,7 +214,15 @@ export function generateColumns<T>({
         if (isPrimitiveOrNullish(value)) {
           const rendered = renderValue();
           return (
-            <div className={getCellStyleClass(justify, wrapped)}>
+            <div
+              onClick={selectCell}
+              className={getCellStyleClass(
+                justify,
+                wrapped,
+                canSelectCell,
+                isCellSelected,
+              )}
+            >
               {rendered == null ? (
                 ""
               ) : typeof rendered === "string" ? (
@@ -206,7 +239,15 @@ export function generateColumns<T>({
           const type =
             column.columnDef.meta?.dataType === "date" ? "date" : "datetime";
           return (
-            <div className={getCellStyleClass(justify, wrapped)}>
+            <div
+              onClick={selectCell}
+              className={getCellStyleClass(
+                justify,
+                wrapped,
+                canSelectCell,
+                isCellSelected,
+              )}
+            >
               <DatePopover date={value} type={type}>
                 {exactDateTime(value)}
               </DatePopover>
@@ -216,14 +257,30 @@ export function generateColumns<T>({
 
         if (isMimeValue(value)) {
           return (
-            <div className={getCellStyleClass(justify, wrapped)}>
+            <div
+              onClick={selectCell}
+              className={getCellStyleClass(
+                justify,
+                wrapped,
+                canSelectCell,
+                isCellSelected,
+              )}
+            >
               <MimeCell value={value} />
             </div>
           );
         }
 
         return (
-          <div className={getCellStyleClass(justify, wrapped)}>
+          <div
+            onClick={selectCell}
+            className={getCellStyleClass(
+              justify,
+              wrapped,
+              canSelectCell,
+              isCellSelected,
+            )}
+          >
             {renderAny(getValue())}
           </div>
         );
@@ -307,8 +364,12 @@ function getFilterTypeForFieldType(
 function getCellStyleClass(
   justify: "left" | "center" | "right" | undefined,
   wrapped: boolean | undefined,
+  canSelectCell: boolean,
+  isSelected: boolean,
 ): string {
   return cn(
+    canSelectCell && "cursor-pointer",
+    isSelected && "bg-[var(--blue-2)]",
     "w-full",
     "text-left",
     justify === "center" && "text-center",
diff --git a/frontend/src/components/data-table/data-table.tsx b/frontend/src/components/data-table/data-table.tsx
index 4224c08b..b6ae97c9 100644
--- a/frontend/src/components/data-table/data-table.tsx
+++ b/frontend/src/components/data-table/data-table.tsx
@@ -29,7 +29,10 @@ import { SearchBar } from "./SearchBar";
 import { TableActions } from "./TableActions";
 import { ColumnFormattingFeature } from "./column-formatting/feature";
 import { ColumnWrappingFeature } from "./column-wrapping/feature";
+import type { DataTableSelection } from "./types";
 import { INDEX_COLUMN_NAME } from "./types";
+import { CellSelectionFeature } from "./cell-selection/feature";
+import type { CellSelectionState } from "./cell-selection/types";
 import type { GetRowIds } from "@/plugins/impl/DataTablePlugin";
 
 interface DataTableProps<TData> extends Partial<DownloadActionProps> {
@@ -49,9 +52,10 @@ interface DataTableProps<TData> extends Partial<DownloadActionProps> {
   paginationState?: PaginationState; // controlled pagination
   setPaginationState?: OnChangeFn<PaginationState>; // controlled pagination
   // Selection
-  selection?: "single" | "multi" | null;
+  selection?: DataTableSelection;
   rowSelection?: RowSelectionState;
   onRowSelectionChange?: OnChangeFn<RowSelectionState>;
+  onCellSelectionChange?: OnChangeFn<CellSelectionState>;
   getRowIds?: GetRowIds;
   // Search
   enableSearch?: boolean;
@@ -84,6 +88,7 @@ const DataTableInternal = <TData,>({
   manualPagination = false,
   pagination = false,
   onRowSelectionChange,
+  onCellSelectionChange,
   getRowIds,
   enableSearch = false,
   searchQuery,
@@ -103,7 +108,12 @@ const DataTableInternal = <TData,>({
   );
 
   const table = useReactTable<TData>({
-    _features: [ColumnPinning, ColumnWrappingFeature, ColumnFormattingFeature],
+    _features: [
+      ColumnPinning,
+      ColumnWrappingFeature,
+      ColumnFormattingFeature,
+      CellSelectionFeature,
+    ],
     data,
     columns,
     getCoreRowModel: getCoreRowModel(),
@@ -143,6 +153,10 @@ const DataTableInternal = <TData,>({
     onColumnFiltersChange: onFiltersChange,
     // selection
     onRowSelectionChange: onRowSelectionChange,
+    onCellSelectionChange: onCellSelectionChange,
+    enableCellSelection:
+      selection === "single-cell" || selection === "multi-cell",
+    enableMultiCellSelection: selection === "multi-cell",
     state: {
       ...(sorting ? { sorting } : {}),
       columnFilters: filters,
diff --git a/frontend/src/components/data-table/pagination.tsx b/frontend/src/components/data-table/pagination.tsx
index 2db23a24..2a6c2ed1 100644
--- a/frontend/src/components/data-table/pagination.tsx
+++ b/frontend/src/components/data-table/pagination.tsx
@@ -12,10 +12,11 @@ import {
 import { Button } from "@/components/ui/button";
 import { PluralWord } from "@/utils/pluralize";
 import { range } from "lodash-es";
+import type { DataTableSelection } from "./types";
 
 interface DataTablePaginationProps<TData> {
   table: Table<TData>;
-  selection?: "single" | "multi" | null;
+  selection?: DataTableSelection;
   totalColumns: number;
   onSelectAllRowsChange?: (value: boolean) => void;
 }
@@ -27,10 +28,19 @@ export const DataTablePagination = <TData,>({
   totalColumns,
 }: DataTablePaginationProps<TData>) => {
   const renderTotal = () => {
-    const selected = Object.keys(table.getState().rowSelection).length;
-    const isAllPageSelected = table.getIsAllPageRowsSelected();
+    const { rowSelection, cellSelection } = table.getState();
+    let selected = Object.keys(rowSelection).length;
+    let isAllPageSelected = table.getIsAllPageRowsSelected();
     const numRows = table.getRowCount();
-    const isAllSelected = selected === numRows;
+    let isAllSelected = selected === numRows;
+
+    const isCellSelection =
+      selection === "single-cell" || selection === "multi-cell";
+    if (isCellSelection) {
+      selected = cellSelection.length;
+      isAllPageSelected = false;
+      isAllSelected = false;
+    }
 
     if (isAllPageSelected && !isAllSelected) {
       return (
@@ -65,10 +75,14 @@ export const DataTablePagination = <TData,>({
             variant="link"
             className="h-4"
             onClick={() => {
-              if (onSelectAllRowsChange) {
-                onSelectAllRowsChange(false);
-              } else {
-                table.toggleAllRowsSelected(false);
+              if (!isCellSelection) {
+                if (onSelectAllRowsChange) {
+                  onSelectAllRowsChange(false);
+                } else {
+                  table.toggleAllRowsSelected(false);
+                }
+              } else if (table.resetCellSelection) {
+                table.resetCellSelection();
               }
             }}
           >
diff --git a/frontend/src/components/data-table/types.ts b/frontend/src/components/data-table/types.ts
index 1fc4b829..4630a241 100644
--- a/frontend/src/components/data-table/types.ts
+++ b/frontend/src/components/data-table/types.ts
@@ -31,3 +31,10 @@ export function toFieldTypes(
 export const SELECT_COLUMN_ID = "__select__";
 
 export const INDEX_COLUMN_NAME = "_marimo_row_id";
+
+export type DataTableSelection =
+  | "single"
+  | "multi"
+  | "single-cell"
+  | "multi-cell"
+  | null;
diff --git a/frontend/src/plugins/impl/DataTablePlugin.tsx b/frontend/src/plugins/impl/DataTablePlugin.tsx
index 89d3d378..0bbad91f 100644
--- a/frontend/src/plugins/impl/DataTablePlugin.tsx
+++ b/frontend/src/plugins/impl/DataTablePlugin.tsx
@@ -18,6 +18,7 @@ import { ColumnChartContext } from "@/components/data-table/column-summary";
 import { Logger } from "@/utils/Logger";
 
 import {
+  type DataTableSelection,
   toFieldTypes,
   type ColumnHeaderSummary,
   type FieldTypesWithExternalType,
@@ -45,6 +46,7 @@ import { useDeepCompareMemoize } from "@/hooks/useDeepCompareMemoize";
 import { DelayMount } from "@/components/utils/delay-mount";
 import { DATA_TYPES } from "@/core/kernel/messages";
 import { useEffectSkipFirstRender } from "@/hooks/useEffectSkipFirstRender";
+import type { CellSelectionState } from "@/components/data-table/cell-selection/types";
 
 type CsvURL = string;
 type TableData<T> = T[] | CsvURL;
@@ -72,7 +74,7 @@ interface Data<T> {
   totalRows: number | "too_many";
   pagination: boolean;
   pageSize: number;
-  selection: "single" | "multi" | null;
+  selection: DataTableSelection;
   showDownload: boolean;
   showFilters: boolean;
   showColumnSummaries: boolean | "stats" | "chart";
@@ -106,7 +108,7 @@ type DataTableFunctions = {
   get_row_ids?: GetRowIds;
 };
 
-type S = Array<string | number>;
+type S = Array<number | string | { rowId: string; columnName?: string }>;
 
 export const DataTablePlugin = createPlugin<S>("marimo-table")
   .withData(
@@ -117,7 +119,10 @@ export const DataTablePlugin = createPlugin<S>("marimo-table")
       totalRows: z.union([z.number(), z.literal("too_many")]),
       pagination: z.boolean().default(false),
       pageSize: z.number().default(10),
-      selection: z.enum(["single", "multi"]).nullable().default(null),
+      selection: z
+        .enum(["single", "multi", "single-cell", "multi-cell"])
+        .nullable()
+        .default(null),
       showDownload: z.boolean().default(false),
       showFilters: z.boolean().default(false),
       showColumnSummaries: z
@@ -541,6 +546,25 @@ const DataTableComponent = ({
     },
   );
 
+  const cellSelection = value.filter(
+    (v) => v instanceof Object && v.columnName !== undefined,
+  ) as CellSelectionState;
+
+  const handleCellSelectionChange: OnChangeFn<CellSelectionState> = useEvent(
+    (updater) => {
+      if (selection === "single-cell") {
+        const nextValue = Functions.asUpdater(updater)(cellSelection);
+        // This maps to the _value in marimo/_plugins/ui/_impl/table.py I think
+        setValue(nextValue.slice(0, 1));
+      }
+
+      if (selection === "multi-cell") {
+        const nextValue = Functions.asUpdater(updater)(cellSelection);
+        setValue(nextValue);
+      }
+    },
+  );
+
   return (
     <>
       {/* // HACK: We assume "too_many" is coming from a SQL table */}
@@ -591,6 +615,7 @@ const DataTableComponent = ({
             onRowSelectionChange={handleRowSelectionChange}
             freezeColumnsLeft={freezeColumnsLeft}
             freezeColumnsRight={freezeColumnsRight}
+            onCellSelectionChange={handleCellSelectionChange}
             getRowIds={get_row_ids}
           />
         </Labeled>
diff --git a/marimo/_plugins/ui/_impl/table.py b/marimo/_plugins/ui/_impl/table.py
index b06a2322..9c6aee26 100644
--- a/marimo/_plugins/ui/_impl/table.py
+++ b/marimo/_plugins/ui/_impl/table.py
@@ -35,7 +35,9 @@ from marimo._plugins.ui._impl.tables.selection import (
     add_selection_column,
 )
 from marimo._plugins.ui._impl.tables.table_manager import (
+    Cell,
     ColumnName,
+    TableCoordinate,
     TableManager,
 )
 from marimo._plugins.ui._impl.tables.utils import get_table_manager
@@ -113,7 +115,8 @@ class GetRowIdsResponse:
 @mddoc
 class table(
     UIElement[
-        Union[list[str], list[int]], Union[list[JSONType], IntoDataFrame]
+        Union[list[str], list[int], list[dict[str, Any]]],
+        Union[list[JSONType], IntoDataFrame, list[Cell]],
     ]
 ):
     """A table component with selectable rows.
@@ -203,7 +206,8 @@ class table(
             - as a single column: a list of values
         pagination (bool, optional): Whether to paginate; if False, all rows will be shown.
             Defaults to True when above 10 rows, False otherwise.
-        selection (Literal["single", "multi"], optional): 'single' or 'multi' to enable row selection,
+        selection (Literal["single", "multi", "single-cell", "multi-cell"], optional): 'single' or 'multi' to enable row selection,
+            'single-cell' or 'multi-cell' to enable cell selection
             or None to disable. Defaults to "multi".
         initial_selection (List[int], optional): Indices of the rows you want selected by default.
         page_size (int, optional): The number of rows to show per page. Defaults to 10.
@@ -220,7 +224,7 @@ class table(
             Dictionary of column names to text justification options: left, center, right.
         wrapped_columns (List[str], optional): List of column names to wrap.
         label (str, optional): Markdown label for the element. Defaults to "".
-        on_change (Callable[[Union[List[JSONType], Dict[str, List[JSONType]], IntoDataFrame]], None], optional):
+        on_change (Callable[[Union[List[JSONType], Dict[str, List[JSONType]], IntoDataFrame, List[Cell]]], None], optional):
             Optional callback to run when this element's value changes.
         max_columns (int, optional): Maximum number of columns to display. Defaults to 50.
             Set to None to show all columns.
@@ -237,7 +241,9 @@ class table(
             IntoDataFrame,
         ],
         pagination: Optional[bool] = None,
-        selection: Optional[Literal["single", "multi"]] = "multi",
+        selection: Optional[
+            Literal["single", "multi", "single-cell", "multi-cell"]
+        ] = "multi",
         initial_selection: Optional[list[int]] = None,
         page_size: int = 10,
         show_column_summaries: Optional[
@@ -263,6 +269,7 @@ class table(
                         list[JSONType],
                         dict[str, ListOrTuple[JSONType]],
                         IntoDataFrame,
+                        list[Cell],
                     ]
                 ],
                 None,
@@ -317,6 +324,7 @@ class table(
         # Holds the data after user selecting from the component
         self._selected_manager: Optional[TableManager[Any]] = None
 
+        self._selection = selection
         initial_value = []
         if initial_selection and self._manager.supports_selection():
             if selection == "single" and len(initial_selection) > 1:
@@ -452,18 +460,31 @@ class table(
         return ""
 
     def _convert_value(
-        self, value: Union[list[int], list[str]]
-    ) -> Union[list[JSONType], IntoDataFrame]:
-        indices = [int(v) for v in value]
-        if self._has_stable_row_id:
-            # Search across the original data
-            self._selected_manager = self._manager.select_rows(indices)
+        self, value: Union[list[int], list[str], list[dict[str, Any]]]
+    ) -> Union[list[JSONType], IntoDataFrame, list[Cell]]:
+        if self._selection in ["single-cell", "multi-cell"]:
+            coordinates = [
+                TableCoordinate(rowId=v["rowId"], columnName=v["columnName"])
+                for v in value
+                if isinstance(v, dict) and "rowId" in v and "columnName" in v
+            ]
+            self._has_any_selection = len(coordinates) > 0
+            return self._searched_manager.select_cells(coordinates)  # type: ignore
         else:
-            self._selected_manager = self._searched_manager.select_rows(
-                indices
-            )
-        self._has_any_selection = len(indices) > 0
-        return unwrap_narwhals_dataframe(self._selected_manager.data)  # type: ignore[no-any-return]
+            indices = [
+                int(v)
+                for v in value
+                if isinstance(v, int) or isinstance(v, str)
+            ]
+            if self._has_stable_row_id:
+                # Search across the original data
+                self._selected_manager = self._manager.select_rows(indices)
+            else:
+                self._selected_manager = self._searched_manager.select_rows(
+                    indices
+                )
+                self._has_any_selection = len(indices) > 0
+            return unwrap_narwhals_dataframe(self._selected_manager.data)  # type: ignore[no-any-return]
 
     def _download_as(self, args: DownloadAsArgs) -> str:
         """Download the table data in the specified format.
diff --git a/marimo/_plugins/ui/_impl/tables/default_table.py b/marimo/_plugins/ui/_impl/tables/default_table.py
index c96ef6e7..ffc62698 100644
--- a/marimo/_plugins/ui/_impl/tables/default_table.py
+++ b/marimo/_plugins/ui/_impl/tables/default_table.py
@@ -25,9 +25,11 @@ from marimo._plugins.ui._impl.tables.polars_table import (
     PolarsTableManagerFactory,
 )
 from marimo._plugins.ui._impl.tables.table_manager import (
+    Cell,
     ColumnName,
     FieldType,
     FieldTypes,
+    TableCoordinate,
     TableManager,
 )
 
@@ -136,6 +138,53 @@ class DefaultTableManager(TableManager[JsonTableData]):
             ]
         )
 
+    def select_cells(self, cells: list[TableCoordinate]) -> list[Cell]:
+        selected_cells: list[Cell] = []
+        if (
+            self.is_column_oriented
+            and isinstance(self.data, dict)
+            and all(isinstance(v, list) for v in self.data.values())
+        ):
+            for rowId, columnName in cells:
+                column = self.data[columnName]
+                if isinstance(column, Sequence):
+                    selected_cells.append(
+                        Cell(
+                            rowId=rowId,
+                            columnName=columnName,
+                            value=column[int(rowId)],
+                        )
+                    )
+        elif isinstance(self.data, dict):
+            rows_of_dict = list(self.data.items())
+            for rowId, columnName in cells:
+                value = (
+                    rows_of_dict[int(rowId)][0]
+                    if columnName == "key"
+                    else rows_of_dict[int(rowId)][1]
+                )
+                selected_cells.append(
+                    Cell(rowId=rowId, columnName=columnName, value=value)
+                )
+        elif isinstance(self.data, list):
+            rows_of_list = self.data
+            for rowId, columnName in cells:
+                row_index = int(rowId)
+                if row_index < 0 or row_index > len(rows_of_list) - 1:
+                    continue
+
+                row = rows_of_list[row_index]
+                if isinstance(row, dict) and columnName in row:
+                    selected_cells.append(
+                        Cell(
+                            rowId=rowId,
+                            columnName=columnName,
+                            value=row[columnName],
+                        )
+                    )
+
+        return selected_cells
+
     def drop_columns(self, columns: list[str]) -> DefaultTableManager:
         return self.select_columns(
             list(set(self.get_column_names()) - set(columns))
diff --git a/marimo/_plugins/ui/_impl/tables/ibis_table.py b/marimo/_plugins/ui/_impl/tables/ibis_table.py
index 82728d1d..ad19a9bb 100644
--- a/marimo/_plugins/ui/_impl/tables/ibis_table.py
+++ b/marimo/_plugins/ui/_impl/tables/ibis_table.py
@@ -18,8 +18,10 @@ from marimo._plugins.ui._impl.tables.polars_table import (
     PolarsTableManagerFactory,
 )
 from marimo._plugins.ui._impl.tables.table_manager import (
+    Cell,
     ColumnName,
     FieldType,
+    TableCoordinate,
     TableManager,
     TableManagerFactory,
 )
@@ -72,6 +74,11 @@ class IbisTableManagerFactory(TableManagerFactory):
             ) -> TableManager[ibis.Table]:
                 return IbisTableManager(self.data.select(columns))
 
+            def select_cells(
+                self, _cells: list[TableCoordinate]
+            ) -> list[Cell]:
+                raise NotImplementedError("Cell selection not supported")
+
             def drop_columns(
                 self, columns: list[str]
             ) -> TableManager[ibis.Table]:
diff --git a/marimo/_plugins/ui/_impl/tables/narwhals_table.py b/marimo/_plugins/ui/_impl/tables/narwhals_table.py
index 02979790..49f375a7 100644
--- a/marimo/_plugins/ui/_impl/tables/narwhals_table.py
+++ b/marimo/_plugins/ui/_impl/tables/narwhals_table.py
@@ -15,8 +15,10 @@ from marimo._plugins.ui._impl.tables.format import (
 )
 from marimo._plugins.ui._impl.tables.selection import INDEX_COLUMN_NAME
 from marimo._plugins.ui._impl.tables.table_manager import (
+    Cell,
     ColumnName,
     FieldType,
+    TableCoordinate,
     TableManager,
 )
 from marimo._utils.narwhals_utils import (
@@ -100,6 +102,27 @@ class NarwhalsTableManager(
     def select_columns(self, columns: list[str]) -> TableManager[Any]:
         return self.with_new_data(self.data.select(columns))
 
+    def select_cells(self, cells: list[TableCoordinate]) -> list[Cell]:
+        df = self.as_frame()
+        if INDEX_COLUMN_NAME in df.columns:
+            selection: list[Cell] = []
+            for row, col in cells:
+                filtered: nw.DataFrame[Any] = df.filter(
+                    nw.col(INDEX_COLUMN_NAME) == int(row)
+                )
+                if filtered.is_empty():
+                    continue
+
+                filtered_row = filtered[[0]]
+                selection.append(Cell(row, col, filtered_row.get_column(col)))
+
+            return selection
+        else:
+            return [
+                Cell(row, col, df.item(row=int(row), column=col))
+                for row, col in cells
+            ]
+
     def drop_columns(self, columns: list[str]) -> TableManager[Any]:
         return self.with_new_data(self.data.drop(columns, strict=False))
 
diff --git a/marimo/_plugins/ui/_impl/tables/table_manager.py b/marimo/_plugins/ui/_impl/tables/table_manager.py
index a54d513a..2eb065de 100644
--- a/marimo/_plugins/ui/_impl/tables/table_manager.py
+++ b/marimo/_plugins/ui/_impl/tables/table_manager.py
@@ -2,12 +2,8 @@
 from __future__ import annotations
 
 import abc
-from typing import (
-    Any,
-    Generic,
-    Optional,
-    TypeVar,
-)
+from dataclasses import dataclass
+from typing import Any, Generic, NamedTuple, Optional, TypeVar
 
 import marimo._output.data.data as mo_data
 from marimo._data.models import ColumnSummary, DataType, ExternalDataType
@@ -21,6 +17,18 @@ FieldType = DataType
 FieldTypes = list[tuple[ColumnName, tuple[FieldType, ExternalDataType]]]
 
 
+class TableCoordinate(NamedTuple):
+    rowId: str
+    columnName: str
+
+
+@dataclass
+class Cell:
+    rowId: str
+    columnName: str
+    value: Any | None
+
+
 class TableManager(abc.ABC, Generic[T]):
     # Upper limit for column summaries
     # The only sets the default to show column summaries,
@@ -97,6 +105,10 @@ class TableManager(abc.ABC, Generic[T]):
     def select_columns(self, columns: list[str]) -> TableManager[Any]:
         pass
 
+    @abc.abstractmethod
+    def select_cells(self, cells: list[TableCoordinate]) -> list[Cell]:
+        pass
+
     @abc.abstractmethod
     def drop_columns(self, columns: list[str]) -> TableManager[Any]:
         pass


Test patch:
diff --git a/tests/_plugins/ui/_impl/tables/test_default_table.py b/tests/_plugins/ui/_impl/tables/test_default_table.py
index 224a961c..d5c361f1 100644
--- a/tests/_plugins/ui/_impl/tables/test_default_table.py
+++ b/tests/_plugins/ui/_impl/tables/test_default_table.py
@@ -8,6 +8,7 @@ import pytest
 
 from marimo._dependencies.dependencies import DependencyManager
 from marimo._plugins.ui._impl.tables.default_table import DefaultTableManager
+from marimo._plugins.ui._impl.tables.table_manager import Cell, TableCoordinate
 
 HAS_DEPS = DependencyManager.pandas.has()
 
@@ -48,6 +49,20 @@ class TestDefaultTable(unittest.TestCase):
         ]
         assert selected_manager.data == expected_data
 
+    def test_select_cells(self) -> None:
+        cells = [
+            TableCoordinate(rowId=0, columnName="name"),
+            TableCoordinate(rowId=1, columnName="age"),
+            TableCoordinate(rowId=2, columnName="birth_year"),
+        ]
+        selected_cells = self.manager.select_cells(cells)
+        expected_cells = [
+            Cell(rowId=0, columnName="name", value="Alice"),
+            Cell(rowId=1, columnName="age", value=25),
+            Cell(rowId=2, columnName="birth_year", value=date(1989, 12, 1)),
+        ]
+        assert selected_cells == expected_cells
+
     def test_drop_columns(self) -> None:
         columns = ["name"]
         dropped_manager = self.manager.drop_columns(columns)
@@ -334,6 +349,20 @@ class TestColumnarDefaultTable(unittest.TestCase):
         }
         assert selected_manager.data == expected_data
 
+    def test_select_cells(self) -> None:
+        cells = [
+            TableCoordinate(rowId=0, columnName="name"),
+            TableCoordinate(rowId=1, columnName="age"),
+            TableCoordinate(rowId=2, columnName="birth_year"),
+        ]
+        selected_cells = self.manager.select_cells(cells)
+        expected_cells = [
+            Cell(rowId=0, columnName="name", value="Alice"),
+            Cell(rowId=1, columnName="age", value=25),
+            Cell(rowId=2, columnName="birth_year", value=date(1989, 12, 1)),
+        ]
+        assert selected_cells == expected_cells
+
     def test_drop_columns(self) -> None:
         columns = ["name", "birth_year"]
         dropped_manager = self.manager.drop_columns(columns)
@@ -606,6 +635,18 @@ class TestDictionaryDefaultTable(unittest.TestCase):
         selected_manager = self.manager.select_columns(["a"])
         assert selected_manager.data == {"a": 1}
 
+    def test_select_cells(self) -> None:
+        selected_cells = self.manager.select_cells(
+            [
+                TableCoordinate(rowId=0, columnName="key"),
+                TableCoordinate(rowId=1, columnName="value"),
+            ]
+        )
+        assert selected_cells == [
+            Cell(rowId=0, columnName="key", value="a"),
+            Cell(rowId=1, columnName="value", value=2),
+        ]
+
     def test_drop_columns(self) -> None:
         dropped_manager = self.manager.drop_columns(["a"])
         assert dropped_manager.data == {"b": 2}
diff --git a/tests/_plugins/ui/_impl/tables/test_narwhals.py b/tests/_plugins/ui/_impl/tables/test_narwhals.py
index 4959a0ba..bdeee841 100644
--- a/tests/_plugins/ui/_impl/tables/test_narwhals.py
+++ b/tests/_plugins/ui/_impl/tables/test_narwhals.py
@@ -15,7 +15,11 @@ from marimo._plugins.ui._impl.tables.format import FormatMapping
 from marimo._plugins.ui._impl.tables.narwhals_table import (
     NarwhalsTableManager,
 )
-from marimo._plugins.ui._impl.tables.table_manager import TableManager
+from marimo._plugins.ui._impl.tables.table_manager import (
+    Cell,
+    TableCoordinate,
+    TableManager,
+)
 from marimo._plugins.ui._impl.tables.utils import get_table_manager
 from marimo._utils.narwhals_utils import unwrap_py_scalar
 from tests._data.mocks import create_dataframes
@@ -176,6 +180,24 @@ class TestNarwhalsTableManagerFactory(unittest.TestCase):
         expected_data = self.data.select(columns)
         assert_frame_equal(selected_manager.data, expected_data)
 
+    def test_select_cells(self) -> None:
+        cells = [
+            TableCoordinate(columnName="A", rowId=0),
+            TableCoordinate(columnName="B", rowId=1),
+            TableCoordinate(columnName="C", rowId=2),
+            TableCoordinate(columnName="D", rowId=1),
+            TableCoordinate(columnName="E", rowId=0),
+        ]
+        selected_cells = self.manager.select_cells(cells)
+        expected_cells = [
+            Cell(columnName="A", rowId=0, value=1),
+            Cell(columnName="B", rowId=1, value="b"),
+            Cell(columnName="C", rowId=2, value=3.0),
+            Cell(columnName="D", rowId=1, value=False),
+            Cell(columnName="E", rowId=0, value=datetime.datetime(2021, 1, 1)),
+        ]
+        assert selected_cells == expected_cells
+
     def test_drop_columns(self) -> None:
         columns = ["A"]
         dropped_manager = self.manager.drop_columns(columns)
diff --git a/tests/_plugins/ui/_impl/test_table.py b/tests/_plugins/ui/_impl/test_table.py
index 245864c1..fd694b54 100644
--- a/tests/_plugins/ui/_impl/test_table.py
+++ b/tests/_plugins/ui/_impl/test_table.py
@@ -11,6 +11,7 @@ from marimo._plugins import ui
 from marimo._plugins.ui._impl.dataframes.transforms.types import Condition
 from marimo._plugins.ui._impl.table import SearchTableArgs, SortArgs
 from marimo._plugins.ui._impl.tables.default_table import DefaultTableManager
+from marimo._plugins.ui._impl.tables.table_manager import Cell
 from marimo._plugins.ui._impl.utils.dataframe import TableData
 from marimo._runtime.functions import EmptyArgs
 from marimo._runtime.runtime import Kernel
@@ -220,7 +221,10 @@ def test_value() -> None:
 def test_value_with_selection() -> None:
     data = ["banana", "apple", "cherry", "date", "elderberry"]
     table = ui.table(data)
-    assert list(table._convert_value(["0", "2"])) == ["banana", "cherry"]
+    assert list(table._convert_value(["0", "2"])) == [
+        "banana",
+        "cherry",
+    ]
 
 
 def test_value_with_initial_selection() -> None:
@@ -390,6 +394,54 @@ def test_value_with_search_then_selection_dfs(df: Any) -> None:
     assert nw.from_native(value)["a"][0] == "baz"
 
 
+@pytest.mark.parametrize(
+    "df",
+    create_dataframes(
+        {"a": ["foo", "bar", "baz"]}, exclude=["ibis", "duckdb", "pyarrow"]
+    ),
+)
+def test_value_with_search_then_cell_selection_dfs(df: Any) -> None:
+    import narwhals as nw
+
+    table = ui.table(df, selection="multi-cell")
+    table._search(
+        SearchTableArgs(
+            query="bar",
+            page_size=10,
+            page_number=0,
+        )
+    )
+    value = table._convert_value([{"rowId": "1", "columnName": "a"}])
+    assert not isinstance(value, nw.DataFrame)
+    assert value[0].value == "bar"
+
+    table._search(
+        SearchTableArgs(
+            query="foo",
+            page_size=10,
+            page_number=0,
+        )
+    )
+    # Can still select rows not in the search
+    value = table._convert_value(
+        [{"rowId": 0, "columnName": "a"}, {"rowId": 1, "columnName": "a"}]
+    )
+    assert not isinstance(value, nw.DataFrame)
+    assert value[0].value == "foo"
+    assert len(value) == 1
+
+    # empty search
+    table._search(
+        SearchTableArgs(
+            page_size=10,
+            page_number=0,
+        )
+    )
+    value = table._convert_value([{"rowId": "2", "columnName": "a"}])
+    assert not isinstance(value, nw.DataFrame)
+    assert value[0].value == "baz"
+
+
 def test_value_with_selection_then_sorting_dict_of_lists() -> None:
     data = {
         "company": [
@@ -428,6 +480,60 @@ def test_value_with_selection_then_sorting_dict_of_lists() -> None:
     ]
 
 
+def test_value_with_cell_selection_then_sorting_dict_of_lists() -> None:
+    data = {
+        "company": [
+            "Company A",
+            "Company B",
+            "Company C",
+            "Company D",
+            "Company E",
+        ],
+        "type": ["Tech", "Finance", "Health", "Tech", "Finance"],
+        "net_worth": [1000, 2000, 1500, 1800, 1700],
+    }
+    table = ui.table(data, selection="multi-cell")
+
+    table._search(
+        SearchTableArgs(
+            page_size=10,
+            page_number=0,
+        )
+    )
+    assert table._convert_value(
+        [
+            {"rowId": "0", "columnName": "company"},
+            {"rowId": "2", "columnName": "company"},
+        ]
+    ) == [
+        Cell(rowId="0", columnName="company", value="Company A"),
+        Cell(rowId="2", columnName="company", value="Company C"),
+    ]
+
+    table._search(
+        SearchTableArgs(
+            sort=SortArgs("net_worth", descending=True),
+            page_size=10,
+            page_number=0,
+        )
+    )
+    assert table._convert_value(
+        [
+            {"rowId": "0", "columnName": "company"},
+            {"rowId": "2", "columnName": "company"},
+        ]
+    ) == [
+        Cell(rowId="0", columnName="company", value="Company B"),
+        Cell(rowId="2", columnName="company", value="Company E"),
+    ]
+
+
+@pytest.mark.parametrize("df", create_dataframes({"a": [1, 2, 3]}, ["ibis"]))
+def test_value_with_cell_selection_unsupported_for_ibis(df: Any) -> None:
+    with pytest.raises(NotImplementedError):
+        _table = ui.table(df, selection="multi-cell")
+
+
 def test_search_sort_nonexistent_columns() -> None:
     data = ["banana", "apple", "cherry", "date", "elderberry"]
     table = ui.table(data)