diff --git a/jest.setup.ts b/jest.setup.ts
index 4bb196107b..8db731f817 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -50,6 +50,21 @@ Object.defineProperty(window, 'ResizeObserver', {
   },
 });
 
+Object.defineProperty(window, 'DOMRect', {
+  value: function (x: number = 0, y: number = 0, width = 0, height = 0) {
+    return TestUtils.createMockProxy<DOMRect>({
+      x,
+      y,
+      width,
+      height,
+      top: y,
+      bottom: y + height,
+      left: x,
+      right: x + width,
+    });
+  },
+});
+
 Object.defineProperty(window, 'TextDecoder', {
   value: TextDecoder,
 });
diff --git a/packages/code-studio/src/styleguide/ListViews.tsx b/packages/code-studio/src/styleguide/ListViews.tsx
new file mode 100644
index 0000000000..d52fe3672e
--- /dev/null
+++ b/packages/code-studio/src/styleguide/ListViews.tsx
@@ -0,0 +1,123 @@
+import React, { useCallback, useState } from 'react';
+import {
+  Grid,
+  Icon,
+  Item,
+  ListView,
+  ItemKey,
+  Text,
+} from '@deephaven/components';
+import { vsAccount, vsPerson } from '@deephaven/icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { generateNormalizedItems, sampleSectionIdAndClasses } from './utils';
+
+// Generate enough items to require scrolling
+const itemsSimple = [...generateNormalizedItems(52)];
+
+function AccountIllustration(): JSX.Element {
+  return (
+    // Images in ListView items require a slot of 'image' or 'illustration' to
+    // be set in order to be positioned correctly:
+    // https://github.com/adobe/react-spectrum/blob/784737effd44b9d5e2b1316e690da44555eafd7e/packages/%40react-spectrum/list/src/ListViewItem.tsx#L266-L267
+    <Icon slot="illustration">
+      <FontAwesomeIcon icon={vsAccount} />
+    </Icon>
+  );
+}
+
+export function ListViews(): JSX.Element {
+  const [selectedKeys, setSelectedKeys] = useState<'all' | Iterable<ItemKey>>(
+    []
+  );
+
+  const onChange = useCallback((keys: 'all' | Iterable<ItemKey>): void => {
+    setSelectedKeys(keys);
+  }, []);
+
+  return (
+    // eslint-disable-next-line react/jsx-props-no-spreading
+    <div {...sampleSectionIdAndClasses('list-views')}>
+      <h2 className="ui-title">List View</h2>
+
+      <Grid columnGap={14} height="size-6000">
+        <Text>Single Child</Text>
+        <ListView
+          density="compact"
+          gridRow="2"
+          aria-label="Single Child"
+          selectionMode="multiple"
+        >
+          <Item>Aaa</Item>
+        </ListView>
+
+        <label>Icons</label>
+        <ListView
+          gridRow="2"
+          aria-label="Icon"
+          density="compact"
+          selectionMode="multiple"
+        >
+          <Item textValue="Item with icon A">
+            <AccountIllustration />
+            <Text>Item with icon A</Text>
+          </Item>
+          <Item textValue="Item with icon B">
+            <AccountIllustration />
+            <Text>Item with icon B</Text>
+          </Item>
+          <Item textValue="Item with icon C">
+            <AccountIllustration />
+            <Text>Item with icon C</Text>
+          </Item>
+          <Item textValue="Item with icon D">
+            <AccountIllustration />
+            <Text>Item with icon D with overflowing content</Text>
+          </Item>
+        </ListView>
+
+        <label>Mixed Children Types</label>
+        <ListView
+          gridRow="2"
+          aria-label="Mixed Children Types"
+          density="compact"
+          maxWidth="size-2400"
+          selectionMode="multiple"
+          defaultSelectedKeys={[999, 444]}
+        >
+          {/* eslint-disable react/jsx-curly-brace-presence */}
+          {'String 1'}
+          {'String 2'}
+          {'String 3'}
+          {''}
+          {'Some really long text that should get truncated'}
+          {/* eslint-enable react/jsx-curly-brace-presence */}
+          {444}
+          {999}
+          {true}
+          {false}
+          <Item>Item Aaa</Item>
+          <Item>Item Bbb</Item>
+          <Item textValue="Complex Ccc">
+            <Icon slot="image">
+              <FontAwesomeIcon icon={vsPerson} />
+            </Icon>
+            <Text>Complex Ccc with text that should be truncated</Text>
+          </Item>
+        </ListView>
+
+        <label>Controlled</label>
+        <ListView
+          gridRow="2"
+          aria-label="Controlled"
+          selectionMode="multiple"
+          selectedKeys={selectedKeys}
+          onChange={onChange}
+        >
+          {itemsSimple}
+        </ListView>
+      </Grid>
+    </div>
+  );
+}
+
+export default ListViews;
diff --git a/packages/code-studio/src/styleguide/Pickers.tsx b/packages/code-studio/src/styleguide/Pickers.tsx
index 18dc678c10..89adb62356 100644
--- a/packages/code-studio/src/styleguide/Pickers.tsx
+++ b/packages/code-studio/src/styleguide/Pickers.tsx
@@ -10,15 +10,10 @@ import {
 import { vsPerson } from '@deephaven/icons';
 import { Icon } from '@adobe/react-spectrum';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { sampleSectionIdAndClasses } from './utils';
+import { generateNormalizedItems, sampleSectionIdAndClasses } from './utils';
 
 // Generate enough items to require scrolling
-const items = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
-  .split('')
-  .map((key, i) => ({
-    key,
-    item: { key: (i + 1) * 100, content: `${key}${key}${key}` },
-  }));
+const items = [...generateNormalizedItems(52)];
 
 function PersonIcon(): JSX.Element {
   return (
@@ -29,7 +24,7 @@ function PersonIcon(): JSX.Element {
 }
 
 export function Pickers(): JSX.Element {
-  const [selectedKey, setSelectedKey] = useState<ItemKey>();
+  const [selectedKey, setSelectedKey] = useState<ItemKey | null>(null);
 
   const onChange = useCallback((key: ItemKey): void => {
     setSelectedKey(key);
diff --git a/packages/code-studio/src/styleguide/StyleGuide.test.tsx b/packages/code-studio/src/styleguide/StyleGuide.test.tsx
index f86f510f5e..28355f2637 100644
--- a/packages/code-studio/src/styleguide/StyleGuide.test.tsx
+++ b/packages/code-studio/src/styleguide/StyleGuide.test.tsx
@@ -9,11 +9,37 @@ import StyleGuide from './StyleGuide';
 window.HTMLElement.prototype.scroll = jest.fn();
 window.HTMLElement.prototype.scrollIntoView = jest.fn();
 
+/**
+ * Mock a dimension property of a ListView element.
+ */
+function mockListViewDimension(propName: keyof HTMLElement, value: number) {
+  jest
+    .spyOn(window.HTMLElement.prototype, propName, 'get')
+    .mockImplementation(function getDimension() {
+      const isSpectrumListView =
+        this instanceof HTMLElement &&
+        this.className.includes('_react-spectrum-ListView');
+
+      // For non ListView, just return zero which is the default value anyway.
+      return isSpectrumListView === true ? value : 0;
+    });
+}
+
 describe('<StyleGuide /> mounts', () => {
   test('h1 text of StyleGuide renders', () => {
     // Provide a non-null array to ThemeProvider to tell it to initialize
     const customThemes: ThemeData[] = [];
 
+    // React Spectrum `useVirtualizerItem` depends on `scrollWidth` and `scrollHeight`.
+    // Mocking these to avoid React "Maximum update depth exceeded" errors.
+    // https://github.com/adobe/react-spectrum/blob/0b2a838b36ad6d86eee13abaf68b7e4d2b4ada6c/packages/%40react-aria/virtualizer/src/useVirtualizerItem.ts#L49C3-L49C60
+    // From preview docs: https://reactspectrum.blob.core.windows.net/reactspectrum/726a5e8f0ed50fc8d98e39c74bd6dfeb3660fbdf/docs/react-spectrum/testing.html#virtualized-components
+    // The virtualizer will now think it has a visible area of 1000px x 1000px and that the items within it are 40px x 40px
+    mockListViewDimension('clientWidth', 1000);
+    mockListViewDimension('clientHeight', 1000);
+    mockListViewDimension('scrollHeight', 40);
+    mockListViewDimension('scrollWidth', 40);
+
     expect(() =>
       render(
         <ApiContext.Provider value={dh}>
diff --git a/packages/code-studio/src/styleguide/StyleGuide.tsx b/packages/code-studio/src/styleguide/StyleGuide.tsx
index 051a58917f..72c3689777 100644
--- a/packages/code-studio/src/styleguide/StyleGuide.tsx
+++ b/packages/code-studio/src/styleguide/StyleGuide.tsx
@@ -36,6 +36,7 @@ import { GoldenLayout } from './GoldenLayout';
 import { RandomAreaPlotAnimation } from './RandomAreaPlotAnimation';
 import SpectrumComparison from './SpectrumComparison';
 import Pickers from './Pickers';
+import ListViews from './ListViews';
 
 const stickyProps = {
   position: 'sticky',
@@ -109,6 +110,7 @@ function StyleGuide(): React.ReactElement {
         <Buttons />
         <Progress />
         <Inputs />
+        <ListViews />
         <Pickers />
         <ItemListInputs />
         <DraggableLists />
diff --git a/packages/code-studio/src/styleguide/__snapshots__/utils.test.ts.snap b/packages/code-studio/src/styleguide/__snapshots__/utils.test.ts.snap
new file mode 100644
index 0000000000..a9964ea190
--- /dev/null
+++ b/packages/code-studio/src/styleguide/__snapshots__/utils.test.ts.snap
@@ -0,0 +1,706 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`generateNormalizedItems should generate normalized items 1`] = `
+[
+  {
+    "item": {
+      "content": "AAA",
+      "key": 100,
+    },
+    "key": "A",
+  },
+  {
+    "item": {
+      "content": "BBB",
+      "key": 200,
+    },
+    "key": "B",
+  },
+  {
+    "item": {
+      "content": "CCC",
+      "key": 300,
+    },
+    "key": "C",
+  },
+  {
+    "item": {
+      "content": "DDD",
+      "key": 400,
+    },
+    "key": "D",
+  },
+  {
+    "item": {
+      "content": "EEE",
+      "key": 500,
+    },
+    "key": "E",
+  },
+  {
+    "item": {
+      "content": "FFF",
+      "key": 600,
+    },
+    "key": "F",
+  },
+  {
+    "item": {
+      "content": "GGG",
+      "key": 700,
+    },
+    "key": "G",
+  },
+  {
+    "item": {
+      "content": "HHH",
+      "key": 800,
+    },
+    "key": "H",
+  },
+  {
+    "item": {
+      "content": "III",
+      "key": 900,
+    },
+    "key": "I",
+  },
+  {
+    "item": {
+      "content": "JJJ",
+      "key": 1000,
+    },
+    "key": "J",
+  },
+  {
+    "item": {
+      "content": "KKK",
+      "key": 1100,
+    },
+    "key": "K",
+  },
+  {
+    "item": {
+      "content": "LLL",
+      "key": 1200,
+    },
+    "key": "L",
+  },
+  {
+    "item": {
+      "content": "MMM",
+      "key": 1300,
+    },
+    "key": "M",
+  },
+  {
+    "item": {
+      "content": "NNN",
+      "key": 1400,
+    },
+    "key": "N",
+  },
+  {
+    "item": {
+      "content": "OOO",
+      "key": 1500,
+    },
+    "key": "O",
+  },
+  {
+    "item": {
+      "content": "PPP",
+      "key": 1600,
+    },
+    "key": "P",
+  },
+  {
+    "item": {
+      "content": "QQQ",
+      "key": 1700,
+    },
+    "key": "Q",
+  },
+  {
+    "item": {
+      "content": "RRR",
+      "key": 1800,
+    },
+    "key": "R",
+  },
+  {
+    "item": {
+      "content": "SSS",
+      "key": 1900,
+    },
+    "key": "S",
+  },
+  {
+    "item": {
+      "content": "TTT",
+      "key": 2000,
+    },
+    "key": "T",
+  },
+  {
+    "item": {
+      "content": "UUU",
+      "key": 2100,
+    },
+    "key": "U",
+  },
+  {
+    "item": {
+      "content": "VVV",
+      "key": 2200,
+    },
+    "key": "V",
+  },
+  {
+    "item": {
+      "content": "WWW",
+      "key": 2300,
+    },
+    "key": "W",
+  },
+  {
+    "item": {
+      "content": "XXX",
+      "key": 2400,
+    },
+    "key": "X",
+  },
+  {
+    "item": {
+      "content": "YYY",
+      "key": 2500,
+    },
+    "key": "Y",
+  },
+  {
+    "item": {
+      "content": "ZZZ",
+      "key": 2600,
+    },
+    "key": "Z",
+  },
+  {
+    "item": {
+      "content": "aaa",
+      "key": 2700,
+    },
+    "key": "a",
+  },
+  {
+    "item": {
+      "content": "bbb",
+      "key": 2800,
+    },
+    "key": "b",
+  },
+  {
+    "item": {
+      "content": "ccc",
+      "key": 2900,
+    },
+    "key": "c",
+  },
+  {
+    "item": {
+      "content": "ddd",
+      "key": 3000,
+    },
+    "key": "d",
+  },
+  {
+    "item": {
+      "content": "eee",
+      "key": 3100,
+    },
+    "key": "e",
+  },
+  {
+    "item": {
+      "content": "fff",
+      "key": 3200,
+    },
+    "key": "f",
+  },
+  {
+    "item": {
+      "content": "ggg",
+      "key": 3300,
+    },
+    "key": "g",
+  },
+  {
+    "item": {
+      "content": "hhh",
+      "key": 3400,
+    },
+    "key": "h",
+  },
+  {
+    "item": {
+      "content": "iii",
+      "key": 3500,
+    },
+    "key": "i",
+  },
+  {
+    "item": {
+      "content": "jjj",
+      "key": 3600,
+    },
+    "key": "j",
+  },
+  {
+    "item": {
+      "content": "kkk",
+      "key": 3700,
+    },
+    "key": "k",
+  },
+  {
+    "item": {
+      "content": "lll",
+      "key": 3800,
+    },
+    "key": "l",
+  },
+  {
+    "item": {
+      "content": "mmm",
+      "key": 3900,
+    },
+    "key": "m",
+  },
+  {
+    "item": {
+      "content": "nnn",
+      "key": 4000,
+    },
+    "key": "n",
+  },
+  {
+    "item": {
+      "content": "ooo",
+      "key": 4100,
+    },
+    "key": "o",
+  },
+  {
+    "item": {
+      "content": "ppp",
+      "key": 4200,
+    },
+    "key": "p",
+  },
+  {
+    "item": {
+      "content": "qqq",
+      "key": 4300,
+    },
+    "key": "q",
+  },
+  {
+    "item": {
+      "content": "rrr",
+      "key": 4400,
+    },
+    "key": "r",
+  },
+  {
+    "item": {
+      "content": "sss",
+      "key": 4500,
+    },
+    "key": "s",
+  },
+  {
+    "item": {
+      "content": "ttt",
+      "key": 4600,
+    },
+    "key": "t",
+  },
+  {
+    "item": {
+      "content": "uuu",
+      "key": 4700,
+    },
+    "key": "u",
+  },
+  {
+    "item": {
+      "content": "vvv",
+      "key": 4800,
+    },
+    "key": "v",
+  },
+  {
+    "item": {
+      "content": "www",
+      "key": 4900,
+    },
+    "key": "w",
+  },
+  {
+    "item": {
+      "content": "xxx",
+      "key": 5000,
+    },
+    "key": "x",
+  },
+  {
+    "item": {
+      "content": "yyy",
+      "key": 5100,
+    },
+    "key": "y",
+  },
+  {
+    "item": {
+      "content": "zzz",
+      "key": 5200,
+    },
+    "key": "z",
+  },
+  {
+    "item": {
+      "content": "AAA1",
+      "key": 5300,
+    },
+    "key": "A1",
+  },
+  {
+    "item": {
+      "content": "BBB1",
+      "key": 5400,
+    },
+    "key": "B1",
+  },
+  {
+    "item": {
+      "content": "CCC1",
+      "key": 5500,
+    },
+    "key": "C1",
+  },
+  {
+    "item": {
+      "content": "DDD1",
+      "key": 5600,
+    },
+    "key": "D1",
+  },
+  {
+    "item": {
+      "content": "EEE1",
+      "key": 5700,
+    },
+    "key": "E1",
+  },
+  {
+    "item": {
+      "content": "FFF1",
+      "key": 5800,
+    },
+    "key": "F1",
+  },
+  {
+    "item": {
+      "content": "GGG1",
+      "key": 5900,
+    },
+    "key": "G1",
+  },
+  {
+    "item": {
+      "content": "HHH1",
+      "key": 6000,
+    },
+    "key": "H1",
+  },
+  {
+    "item": {
+      "content": "III1",
+      "key": 6100,
+    },
+    "key": "I1",
+  },
+  {
+    "item": {
+      "content": "JJJ1",
+      "key": 6200,
+    },
+    "key": "J1",
+  },
+  {
+    "item": {
+      "content": "KKK1",
+      "key": 6300,
+    },
+    "key": "K1",
+  },
+  {
+    "item": {
+      "content": "LLL1",
+      "key": 6400,
+    },
+    "key": "L1",
+  },
+  {
+    "item": {
+      "content": "MMM1",
+      "key": 6500,
+    },
+    "key": "M1",
+  },
+  {
+    "item": {
+      "content": "NNN1",
+      "key": 6600,
+    },
+    "key": "N1",
+  },
+  {
+    "item": {
+      "content": "OOO1",
+      "key": 6700,
+    },
+    "key": "O1",
+  },
+  {
+    "item": {
+      "content": "PPP1",
+      "key": 6800,
+    },
+    "key": "P1",
+  },
+  {
+    "item": {
+      "content": "QQQ1",
+      "key": 6900,
+    },
+    "key": "Q1",
+  },
+  {
+    "item": {
+      "content": "RRR1",
+      "key": 7000,
+    },
+    "key": "R1",
+  },
+  {
+    "item": {
+      "content": "SSS1",
+      "key": 7100,
+    },
+    "key": "S1",
+  },
+  {
+    "item": {
+      "content": "TTT1",
+      "key": 7200,
+    },
+    "key": "T1",
+  },
+  {
+    "item": {
+      "content": "UUU1",
+      "key": 7300,
+    },
+    "key": "U1",
+  },
+  {
+    "item": {
+      "content": "VVV1",
+      "key": 7400,
+    },
+    "key": "V1",
+  },
+  {
+    "item": {
+      "content": "WWW1",
+      "key": 7500,
+    },
+    "key": "W1",
+  },
+  {
+    "item": {
+      "content": "XXX1",
+      "key": 7600,
+    },
+    "key": "X1",
+  },
+  {
+    "item": {
+      "content": "YYY1",
+      "key": 7700,
+    },
+    "key": "Y1",
+  },
+  {
+    "item": {
+      "content": "ZZZ1",
+      "key": 7800,
+    },
+    "key": "Z1",
+  },
+  {
+    "item": {
+      "content": "aaa1",
+      "key": 7900,
+    },
+    "key": "a1",
+  },
+  {
+    "item": {
+      "content": "bbb1",
+      "key": 8000,
+    },
+    "key": "b1",
+  },
+  {
+    "item": {
+      "content": "ccc1",
+      "key": 8100,
+    },
+    "key": "c1",
+  },
+  {
+    "item": {
+      "content": "ddd1",
+      "key": 8200,
+    },
+    "key": "d1",
+  },
+  {
+    "item": {
+      "content": "eee1",
+      "key": 8300,
+    },
+    "key": "e1",
+  },
+  {
+    "item": {
+      "content": "fff1",
+      "key": 8400,
+    },
+    "key": "f1",
+  },
+  {
+    "item": {
+      "content": "ggg1",
+      "key": 8500,
+    },
+    "key": "g1",
+  },
+  {
+    "item": {
+      "content": "hhh1",
+      "key": 8600,
+    },
+    "key": "h1",
+  },
+  {
+    "item": {
+      "content": "iii1",
+      "key": 8700,
+    },
+    "key": "i1",
+  },
+  {
+    "item": {
+      "content": "jjj1",
+      "key": 8800,
+    },
+    "key": "j1",
+  },
+  {
+    "item": {
+      "content": "kkk1",
+      "key": 8900,
+    },
+    "key": "k1",
+  },
+  {
+    "item": {
+      "content": "lll1",
+      "key": 9000,
+    },
+    "key": "l1",
+  },
+  {
+    "item": {
+      "content": "mmm1",
+      "key": 9100,
+    },
+    "key": "m1",
+  },
+  {
+    "item": {
+      "content": "nnn1",
+      "key": 9200,
+    },
+    "key": "n1",
+  },
+  {
+    "item": {
+      "content": "ooo1",
+      "key": 9300,
+    },
+    "key": "o1",
+  },
+  {
+    "item": {
+      "content": "ppp1",
+      "key": 9400,
+    },
+    "key": "p1",
+  },
+  {
+    "item": {
+      "content": "qqq1",
+      "key": 9500,
+    },
+    "key": "q1",
+  },
+  {
+    "item": {
+      "content": "rrr1",
+      "key": 9600,
+    },
+    "key": "r1",
+  },
+  {
+    "item": {
+      "content": "sss1",
+      "key": 9700,
+    },
+    "key": "s1",
+  },
+  {
+    "item": {
+      "content": "ttt1",
+      "key": 9800,
+    },
+    "key": "t1",
+  },
+  {
+    "item": {
+      "content": "uuu1",
+      "key": 9900,
+    },
+    "key": "u1",
+  },
+  {
+    "item": {
+      "content": "vvv1",
+      "key": 10000,
+    },
+    "key": "v1",
+  },
+]
+`;
diff --git a/packages/code-studio/src/styleguide/utils.test.ts b/packages/code-studio/src/styleguide/utils.test.ts
index fdc3f3ad85..0c3e973c7e 100644
--- a/packages/code-studio/src/styleguide/utils.test.ts
+++ b/packages/code-studio/src/styleguide/utils.test.ts
@@ -1,8 +1,16 @@
 import {
+  generateNormalizedItems,
   sampleSectionIdAndClasses,
   sampleSectionIdAndClassesSpectrum,
 } from './utils';
 
+describe('generateNormalizedItems', () => {
+  it('should generate normalized items', () => {
+    const actual = [...generateNormalizedItems(100)];
+    expect(actual).toMatchSnapshot();
+  });
+});
+
 describe('sampleSectionIdAndClasses', () => {
   it('should return id and className', () => {
     const actual = sampleSectionIdAndClasses('some-id', [
diff --git a/packages/code-studio/src/styleguide/utils.ts b/packages/code-studio/src/styleguide/utils.ts
index a256f79b4c..7e7f9087e3 100644
--- a/packages/code-studio/src/styleguide/utils.ts
+++ b/packages/code-studio/src/styleguide/utils.ts
@@ -1,9 +1,39 @@
 import cl from 'classnames';
 import { useCallback, useState } from 'react';
+import { NormalizedItem } from '@deephaven/components';
 
 export const HIDE_FROM_E2E_TESTS_CLASS = 'hide-from-e2e-tests';
 export const SAMPLE_SECTION_CLASS = 'sample-section';
 
+/**
+ * Generate a given number of NormalizedItems.
+ * @param count The number of items to generate
+ */
+export function* generateNormalizedItems(
+  count: number
+): Generator<NormalizedItem> {
+  const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
+  const len = letters.length;
+
+  for (let i = 0; i < count; i += 1) {
+    const charI = i % len;
+    let suffix = String(Math.floor(i / len));
+    if (suffix === '0') {
+      suffix = '';
+    }
+    const letter = letters[charI];
+    const key = `${letter}${suffix}`;
+
+    yield {
+      key,
+      item: {
+        key: (i + 1) * 100,
+        content: `${letter}${letter}${letter}${suffix}`,
+      },
+    };
+  }
+}
+
 /**
  * Pseudo random number generator with seed so we get reproducible output.
  * This is necessary in order for e2e tests to work.
diff --git a/packages/components/src/spectrum/Heading.tsx b/packages/components/src/spectrum/Heading.tsx
index 38682e6ca7..f29e7b2729 100644
--- a/packages/components/src/spectrum/Heading.tsx
+++ b/packages/components/src/spectrum/Heading.tsx
@@ -1,9 +1,10 @@
 /* eslint-disable react/jsx-props-no-spreading */
-import { useMemo } from 'react';
+import { forwardRef, useMemo } from 'react';
 import {
   Heading as SpectrumHeading,
   type HeadingProps as SpectrumHeadingProps,
 } from '@adobe/react-spectrum';
+import type { DOMRefValue } from '@react-types/shared';
 import { type ColorValue, colorValueStyle } from '../theme/colorUtils';
 
 export type HeadingProps = SpectrumHeadingProps & {
@@ -20,7 +21,10 @@ export type HeadingProps = SpectrumHeadingProps & {
  *
  */
 
-export function Heading(props: HeadingProps): JSX.Element {
+export const Heading = forwardRef<
+  DOMRefValue<HTMLHeadingElement>,
+  HeadingProps
+>((props, forwardedRef): JSX.Element => {
   const { color, UNSAFE_style, ...rest } = props;
   const style = useMemo(
     () => ({
@@ -30,7 +34,9 @@ export function Heading(props: HeadingProps): JSX.Element {
     [color, UNSAFE_style]
   );
 
-  return <SpectrumHeading {...rest} UNSAFE_style={style} />;
-}
+  return <SpectrumHeading {...rest} ref={forwardedRef} UNSAFE_style={style} />;
+});
+
+Heading.displayName = 'Heading';
 
 export default Heading;
diff --git a/packages/components/src/spectrum/ItemContent.tsx b/packages/components/src/spectrum/ItemContent.tsx
new file mode 100644
index 0000000000..1526d3ed64
--- /dev/null
+++ b/packages/components/src/spectrum/ItemContent.tsx
@@ -0,0 +1,101 @@
+import {
+  Children,
+  cloneElement,
+  isValidElement,
+  ReactNode,
+  useState,
+} from 'react';
+import cl from 'classnames';
+import { isElementOfType, useCheckOverflow } from '@deephaven/react-hooks';
+import { Text } from './Text';
+import { TooltipOptions } from './utils';
+import ItemTooltip from './ItemTooltip';
+import stylesCommon from '../SpectrumComponent.module.scss';
+
+export interface ItemContentProps {
+  children: ReactNode;
+  tooltipOptions?: TooltipOptions | null;
+}
+
+/**
+ * Item content. Text content will be wrapped in a Spectrum Text
+ * component with ellipsis overflow handling. If text content overflows and
+ * tooltipOptions are provided a tooltip will be displayed when hovering over
+ * the item content.
+ */
+export function ItemContent({
+  children: content,
+  tooltipOptions,
+}: ItemContentProps): JSX.Element | null {
+  const { checkOverflow, isOverflowing, resetIsOverflowing } =
+    useCheckOverflow();
+
+  const [previousContent, setPreviousContent] = useState(content);
+
+  // Reset `isOverflowing` if content changes. It will get re-calculated as
+  // `Text` components render.
+  if (previousContent !== content) {
+    setPreviousContent(content);
+    resetIsOverflowing();
+  }
+
+  if (isValidElement(content)) {
+    return content;
+  }
+
+  /* eslint-disable no-param-reassign */
+  if (content === '') {
+    // Prevent the item height from collapsing when the content is empty
+    content = '\xa0'; // Non-breaking space
+  } else if (typeof content === 'boolean') {
+    // Boolean values need to be stringified to render
+    content = String(content);
+  } else if (Array.isArray(content)) {
+    // For cases where there are multiple `Text` children, add a css class to
+    // handle overflow. The primary use case for multiple text nodes is when a
+    // description is provided for an item. e.g.
+    // <Item textValue="Some Text">
+    //   <SomeIcon />
+    //   <Text>Some Label</Text>
+    //   <Text slot="description">Some Description</Text>
+    // </Item>
+    content = Children.map(content, el =>
+      isElementOfType(el, Text)
+        ? cloneElement(el, {
+            ...el.props,
+            ref: checkOverflow,
+            UNSAFE_className: cl(
+              el.props.UNSAFE_className,
+              stylesCommon.spectrumEllipsis
+            ),
+          })
+        : el
+    );
+  }
+
+  if (typeof content === 'string' || typeof content === 'number') {
+    content = (
+      <Text
+        ref={checkOverflow}
+        UNSAFE_className={stylesCommon.spectrumEllipsis}
+      >
+        {content}
+      </Text>
+    );
+  }
+  /* eslint-enable no-param-reassign */
+
+  const tooltip =
+    tooltipOptions == null || !isOverflowing ? null : (
+      <ItemTooltip options={tooltipOptions}>{content}</ItemTooltip>
+    );
+
+  return (
+    <>
+      {content}
+      {tooltip}
+    </>
+  );
+}
+
+export default ItemContent;
diff --git a/packages/components/src/spectrum/ItemTooltip.tsx b/packages/components/src/spectrum/ItemTooltip.tsx
new file mode 100644
index 0000000000..a86588d2ff
--- /dev/null
+++ b/packages/components/src/spectrum/ItemTooltip.tsx
@@ -0,0 +1,36 @@
+import { ReactNode } from 'react';
+import { isElementOfType } from '@deephaven/react-hooks';
+import { TooltipOptions } from './utils';
+import { Tooltip } from '../popper';
+import { Flex } from './layout';
+import { Text } from './Text';
+
+export interface ItemTooltipProps {
+  children: ReactNode;
+  options: TooltipOptions;
+}
+
+/**
+ * Tooltip for `<Item>` content.
+ */
+export function ItemTooltip({
+  children,
+  options,
+}: ItemTooltipProps): JSX.Element {
+  if (Array.isArray(children)) {
+    return (
+      <Tooltip options={options}>
+        {/* Multiple children scenarios include a `<Text>` node for the label 
+        and at least 1 of an optional icon or `<Text slot="description">` node.
+        In such cases we only show the label and description `<Text>` nodes. */}
+        <Flex direction="column" alignItems="start">
+          {children.filter(node => isElementOfType(node, Text))}
+        </Flex>
+      </Tooltip>
+    );
+  }
+
+  return <Tooltip options={options}>{children}</Tooltip>;
+}
+
+export default ItemTooltip;
diff --git a/packages/components/src/spectrum/Text.tsx b/packages/components/src/spectrum/Text.tsx
index 3164c2eafd..d0467f275d 100644
--- a/packages/components/src/spectrum/Text.tsx
+++ b/packages/components/src/spectrum/Text.tsx
@@ -1,9 +1,10 @@
 /* eslint-disable react/jsx-props-no-spreading */
-import { useMemo } from 'react';
+import { forwardRef, useMemo } from 'react';
 import {
   Text as SpectrumText,
   type TextProps as SpectrumTextProps,
 } from '@adobe/react-spectrum';
+import type { DOMRefValue } from '@react-types/shared';
 import { type ColorValue, colorValueStyle } from '../theme/colorUtils';
 
 export type TextProps = SpectrumTextProps & {
@@ -19,18 +20,21 @@ export type TextProps = SpectrumTextProps & {
  * @returns The Text component
  *
  */
+export const Text = forwardRef<DOMRefValue<HTMLSpanElement>, TextProps>(
+  (props, forwardedRef): JSX.Element => {
+    const { color, UNSAFE_style, ...rest } = props;
+    const style = useMemo(
+      () => ({
+        ...UNSAFE_style,
+        color: colorValueStyle(color),
+      }),
+      [color, UNSAFE_style]
+    );
 
-export function Text(props: TextProps): JSX.Element {
-  const { color, UNSAFE_style, ...rest } = props;
-  const style = useMemo(
-    () => ({
-      ...UNSAFE_style,
-      color: colorValueStyle(color),
-    }),
-    [color, UNSAFE_style]
-  );
+    return <SpectrumText {...rest} ref={forwardedRef} UNSAFE_style={style} />;
+  }
+);
 
-  return <SpectrumText {...rest} UNSAFE_style={style} />;
-}
+Text.displayName = 'Text';
 
 export default Text;
diff --git a/packages/components/src/spectrum/View.tsx b/packages/components/src/spectrum/View.tsx
index 847900541e..03c28a193e 100644
--- a/packages/components/src/spectrum/View.tsx
+++ b/packages/components/src/spectrum/View.tsx
@@ -1,9 +1,10 @@
 /* eslint-disable react/jsx-props-no-spreading */
-import { useMemo } from 'react';
+import { forwardRef, useMemo } from 'react';
 import {
   View as SpectrumView,
   type ViewProps as SpectrumViewProps,
 } from '@adobe/react-spectrum';
+import type { DOMRefValue } from '@react-types/shared';
 import { type ColorValue, colorValueStyle } from '../theme/colorUtils';
 
 export type ViewProps = Omit<SpectrumViewProps<6>, 'backgroundColor'> & {
@@ -20,17 +21,21 @@ export type ViewProps = Omit<SpectrumViewProps<6>, 'backgroundColor'> & {
  *
  */
 
-export function View(props: ViewProps): JSX.Element {
-  const { backgroundColor, UNSAFE_style, ...rest } = props;
-  const style = useMemo(
-    () => ({
-      ...UNSAFE_style,
-      backgroundColor: colorValueStyle(backgroundColor),
-    }),
-    [backgroundColor, UNSAFE_style]
-  );
+export const View = forwardRef<DOMRefValue<HTMLElement>, ViewProps>(
+  (props, forwardedRef): JSX.Element => {
+    const { backgroundColor, UNSAFE_style, ...rest } = props;
+    const style = useMemo(
+      () => ({
+        ...UNSAFE_style,
+        backgroundColor: colorValueStyle(backgroundColor),
+      }),
+      [backgroundColor, UNSAFE_style]
+    );
 
-  return <SpectrumView {...rest} UNSAFE_style={style} />;
-}
+    return <SpectrumView {...rest} ref={forwardedRef} UNSAFE_style={style} />;
+  }
+);
+
+View.displayName = 'View';
 
 export default View;
diff --git a/packages/components/src/spectrum/collections.ts b/packages/components/src/spectrum/collections.ts
index 619027a946..a1d502975c 100644
--- a/packages/components/src/spectrum/collections.ts
+++ b/packages/components/src/spectrum/collections.ts
@@ -3,8 +3,6 @@ export {
   type SpectrumActionBarProps as ActionBarProps,
   ActionMenu,
   type SpectrumActionMenuProps as ActionMenuProps,
-  ListView,
-  type SpectrumListViewProps as ListViewProps,
   MenuTrigger,
   type SpectrumMenuTriggerProps as MenuTriggerProps,
   TagGroup,
diff --git a/packages/components/src/spectrum/icons.ts b/packages/components/src/spectrum/icons.ts
new file mode 100644
index 0000000000..ac724b4335
--- /dev/null
+++ b/packages/components/src/spectrum/icons.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export { Icon } from '@adobe/react-spectrum';
diff --git a/packages/components/src/spectrum/index.ts b/packages/components/src/spectrum/index.ts
index e001ef8beb..79dee1031d 100644
--- a/packages/components/src/spectrum/index.ts
+++ b/packages/components/src/spectrum/index.ts
@@ -6,6 +6,7 @@ export * from './collections';
 export * from './content';
 export * from './dateAndTime';
 export * from './forms';
+export * from './icons';
 export * from './layout';
 export * from './navigation';
 export * from './overlays';
@@ -16,6 +17,7 @@ export * from './status';
 /**
  * Custom DH components wrapping React Spectrum components.
  */
+export * from './listView';
 export * from './picker';
 export * from './Heading';
 export * from './Text';
@@ -24,4 +26,6 @@ export * from './View';
 /**
  * Custom DH spectrum utils
  */
+export * from './ItemContent';
+export * from './ItemTooltip';
 export * from './utils';
diff --git a/packages/components/src/spectrum/listView/ListView.tsx b/packages/components/src/spectrum/listView/ListView.tsx
new file mode 100644
index 0000000000..5b748518b4
--- /dev/null
+++ b/packages/components/src/spectrum/listView/ListView.tsx
@@ -0,0 +1,147 @@
+import { useMemo } from 'react';
+import cl from 'classnames';
+import {
+  ListView as SpectrumListView,
+  SpectrumListViewProps,
+} from '@adobe/react-spectrum';
+import { EMPTY_FUNCTION } from '@deephaven/utils';
+import {
+  extractSpectrumHTMLElement,
+  useContentRect,
+  useOnScrollRef,
+} from '@deephaven/react-hooks';
+import { Flex } from '../layout';
+import {
+  ItemElementOrPrimitive,
+  ItemKey,
+  ItemSelection,
+  NormalizedItem,
+  normalizeItemList,
+  normalizeTooltipOptions,
+  TooltipOptions,
+  useRenderNormalizedItem,
+  useStringifiedMultiSelection,
+} from '../utils';
+
+export type ListViewProps = {
+  children:
+    | ItemElementOrPrimitive
+    | ItemElementOrPrimitive[]
+    | NormalizedItem[];
+  /** Can be set to true or a TooltipOptions to enable item tooltips */
+  tooltip?: boolean | TooltipOptions;
+  selectedKeys?: 'all' | Iterable<ItemKey>;
+  defaultSelectedKeys?: 'all' | Iterable<ItemKey>;
+  disabledKeys?: Iterable<ItemKey>;
+  /**
+   * Handler that is called when the selection change.
+   * Note that under the hood, this is just an alias for Spectrum's
+   * `onSelectionChange`. We are renaming for better consistency with other
+   * components.
+   */
+  onChange?: (keys: ItemSelection) => void;
+
+  /** Handler that is called when the picker is scrolled. */
+  onScroll?: (event: Event) => void;
+
+  /**
+   * Handler that is called when the selection changes.
+   * @deprecated Use `onChange` instead
+   */
+  onSelectionChange?: (keys: ItemSelection) => void;
+} & Omit<
+  SpectrumListViewProps<NormalizedItem>,
+  | 'children'
+  | 'items'
+  | 'selectedKeys'
+  | 'defaultSelectedKeys'
+  | 'disabledKeys'
+  | 'onSelectionChange'
+>;
+
+export function ListView({
+  children,
+  tooltip = true,
+  selectedKeys,
+  defaultSelectedKeys,
+  disabledKeys,
+  UNSAFE_className,
+  onChange,
+  onScroll = EMPTY_FUNCTION,
+  onSelectionChange,
+  ...spectrumListViewProps
+}: ListViewProps): JSX.Element | null {
+  const normalizedItems = useMemo(
+    () => normalizeItemList(children),
+    [children]
+  );
+
+  const tooltipOptions = useMemo(
+    () => normalizeTooltipOptions(tooltip, 'bottom'),
+    [tooltip]
+  );
+
+  const renderNormalizedItem = useRenderNormalizedItem(tooltipOptions);
+
+  const {
+    selectedStringKeys,
+    defaultSelectedStringKeys,
+    disabledStringKeys,
+    onStringSelectionChange,
+  } = useStringifiedMultiSelection({
+    normalizedItems,
+    selectedKeys,
+    defaultSelectedKeys,
+    disabledKeys,
+    onChange: onChange ?? onSelectionChange,
+  });
+
+  const scrollRef = useOnScrollRef(onScroll, extractSpectrumHTMLElement);
+
+  // Spectrum ListView crashes when it has zero height. Track the contentRect
+  // of the parent container and only render the ListView when it has a non-zero
+  // height. See https://github.com/adobe/react-spectrum/issues/6213
+  const { ref: contentRectRef, contentRect } = useContentRect(
+    extractSpectrumHTMLElement
+  );
+
+  return (
+    <Flex
+      ref={contentRectRef}
+      direction="column"
+      flex={spectrumListViewProps.flex ?? 1}
+      minHeight={0}
+      UNSAFE_className={cl('dh-list-view', UNSAFE_className)}
+    >
+      {contentRect.height === 0 ? (
+        // Use &nbsp; to ensure content has a non-zero height. This ensures the
+        // container will also have a non-zero height unless its height is
+        // explicitly set to zero. Example use case:
+        // 1. Tab containing ListView is visible. Container height is non-zero.
+        //    ListView is rendered.
+        // 2. Tab is hidden. Container height is explicitly constrained to zero.
+        //    ListView is not rendered.
+        // 3. Tab is shown again. Height constraint is removed. Resize observer
+        //    fires and shows non-zero height due to the &nbsp; (without this,
+        //    the height would remain zero forever since ListView hasn't rendered yet)
+        // 4. ListView is rendered again.
+        <>&nbsp;</>
+      ) : (
+        <SpectrumListView
+          // eslint-disable-next-line react/jsx-props-no-spreading
+          {...spectrumListViewProps}
+          ref={scrollRef}
+          items={normalizedItems}
+          selectedKeys={selectedStringKeys}
+          defaultSelectedKeys={defaultSelectedStringKeys}
+          disabledKeys={disabledStringKeys}
+          onSelectionChange={onStringSelectionChange}
+        >
+          {renderNormalizedItem}
+        </SpectrumListView>
+      )}
+    </Flex>
+  );
+}
+
+export default ListView;
diff --git a/packages/components/src/spectrum/listView/index.ts b/packages/components/src/spectrum/listView/index.ts
new file mode 100644
index 0000000000..e1e4de2f28
--- /dev/null
+++ b/packages/components/src/spectrum/listView/index.ts
@@ -0,0 +1 @@
+export * from './ListView';
diff --git a/packages/components/src/spectrum/picker/Picker.tsx b/packages/components/src/spectrum/picker/Picker.tsx
index e706ede9d8..d4bba67aa8 100644
--- a/packages/components/src/spectrum/picker/Picker.tsx
+++ b/packages/components/src/spectrum/picker/Picker.tsx
@@ -1,10 +1,9 @@
-import { Key, ReactNode, useCallback, useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
 import { DOMRef } from '@react-types/shared';
-import { Flex, Picker as SpectrumPicker } from '@adobe/react-spectrum';
+import { Picker as SpectrumPicker } from '@adobe/react-spectrum';
 import {
   getPositionOfSelectedItem,
   findSpectrumPickerScrollArea,
-  isElementOfType,
   usePopoverOnScrollRef,
 } from '@deephaven/react-hooks';
 import {
@@ -13,7 +12,6 @@ import {
   PICKER_TOP_OFFSET,
 } from '@deephaven/utils';
 import cl from 'classnames';
-import { Tooltip } from '../../popper';
 import {
   isNormalizedSection,
   NormalizedSpectrumPickerProps,
@@ -25,9 +23,8 @@ import {
   ItemKey,
   getItemKey,
 } from '../utils/itemUtils';
-import { PickerItemContent } from './PickerItemContent';
-import { Item, Section } from '../shared';
-import { Text } from '../Text';
+import { Section } from '../shared';
+import { useRenderNormalizedItem } from '../utils';
 
 export type PickerProps = {
   children: ItemOrSection | ItemOrSection[] | NormalizedItem[];
@@ -69,26 +66,6 @@ export type PickerProps = {
   | 'defaultSelectedKey'
 >;
 
-/**
- * Create tooltip content optionally wrapping with a Flex column for array
- * content. This is needed for Items containing description `Text` elements.
- */
-function createTooltipContent(content: ReactNode) {
-  if (typeof content === 'boolean') {
-    return String(content);
-  }
-
-  if (Array.isArray(content)) {
-    return (
-      <Flex direction="column" alignItems="start">
-        {content.filter(node => isElementOfType(node, Text))}
-      </Flex>
-    );
-  }
-
-  return content;
-}
-
 /**
  * Picker component for selecting items from a list of items. Items can be
  * provided via the `items` prop or as children. Each item can be a string,
@@ -120,41 +97,7 @@ export function Picker({
     [tooltip]
   );
 
-  const renderItem = useCallback(
-    (normalizedItem: NormalizedItem) => {
-      const key = getItemKey(normalizedItem);
-      const content = normalizedItem.item?.content ?? '';
-      const textValue = normalizedItem.item?.textValue ?? '';
-
-      return (
-        <Item
-          // Note that setting the `key` prop explicitly on `Item` elements
-          // causes the picker to expect `selectedKey` and `defaultSelectedKey`
-          // to be strings. It also passes the stringified value of the key to
-          // `onSelectionChange` handlers` regardless of the actual type of the
-          // key. We can't really get around setting in order to support Windowed
-          // data, so we'll need to do some manual conversion of keys to strings
-          // in other places of this component.
-          key={key as Key}
-          // The `textValue` prop gets used to provide the content of `<option>`
-          // elements that back the Spectrum Picker. These are not visible in the UI,
-          // but are used for accessibility purposes, so we set to an arbitrary
-          // 'Empty' value so that they are not empty strings.
-          textValue={textValue === '' ? 'Empty' : textValue}
-        >
-          <>
-            <PickerItemContent>{content}</PickerItemContent>
-            {tooltipOptions == null || content === '' ? null : (
-              <Tooltip options={tooltipOptions}>
-                {createTooltipContent(content)}
-              </Tooltip>
-            )}
-          </>
-        </Item>
-      );
-    },
-    [tooltipOptions]
-  );
+  const renderNormalizedItem = useRenderNormalizedItem(tooltipOptions);
 
   const getInitialScrollPositionInternal = useCallback(
     () =>
@@ -208,7 +151,6 @@ export function Picker({
     <SpectrumPicker
       // eslint-disable-next-line react/jsx-props-no-spreading
       {...spectrumPickerProps}
-      // The `ref` prop type defined by React Spectrum is incorrect here
       ref={scrollRef as unknown as DOMRef<HTMLDivElement>}
       onOpenChange={onOpenChangeInternal}
       UNSAFE_className={cl('dh-picker', UNSAFE_className)}
@@ -217,8 +159,12 @@ export function Picker({
       // set on `Item` elements. Since we do this in `renderItem`, we need to
       // ensure that `selectedKey` and `defaultSelectedKey` are strings in order
       // for selection to work.
-      selectedKey={selectedKey?.toString()}
-      defaultSelectedKey={defaultSelectedKey?.toString()}
+      selectedKey={selectedKey == null ? selectedKey : selectedKey.toString()}
+      defaultSelectedKey={
+        defaultSelectedKey == null
+          ? defaultSelectedKey
+          : defaultSelectedKey.toString()
+      }
       // `onChange` is just an alias for `onSelectionChange`
       onSelectionChange={
         onSelectionChangeInternal as NormalizedSpectrumPickerProps['onSelectionChange']
@@ -232,12 +178,12 @@ export function Picker({
               title={itemOrSection.item?.title}
               items={itemOrSection.item?.items}
             >
-              {renderItem}
+              {renderNormalizedItem}
             </Section>
           );
         }
 
-        return renderItem(itemOrSection);
+        return renderNormalizedItem(itemOrSection);
       }}
     </SpectrumPicker>
   );
diff --git a/packages/components/src/spectrum/picker/PickerItemContent.tsx b/packages/components/src/spectrum/picker/PickerItemContent.tsx
deleted file mode 100644
index d680c56ed2..0000000000
--- a/packages/components/src/spectrum/picker/PickerItemContent.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { Children, cloneElement, isValidElement, ReactNode } from 'react';
-import { Text } from '@adobe/react-spectrum';
-import cl from 'classnames';
-import { isElementOfType } from '@deephaven/react-hooks';
-import stylesCommon from '../../SpectrumComponent.module.scss';
-
-export interface PickerItemContentProps {
-  children: ReactNode;
-}
-
-/**
- * Picker item content. Text content will be wrapped in a Spectrum Text
- * component with ellipsis overflow handling.
- */
-export function PickerItemContent({
-  children: content,
-}: PickerItemContentProps): JSX.Element | null {
-  if (isValidElement(content)) {
-    return content;
-  }
-
-  /* eslint-disable no-param-reassign */
-  if (content === '') {
-    // Prevent the item height from collapsing when the content is empty
-    content = '\xa0'; // Non-breaking space
-  } else if (typeof content === 'boolean') {
-    // Boolean values need to be stringified to render
-    content = String(content);
-  } else if (Array.isArray(content)) {
-    // For cases where there are multiple `Text` children, add a css class to
-    // handle overflow. The primary use case for multiple text nodes is when a
-    // description is provided for an item. e.g.
-    // <Item textValue="Some Text">
-    //   <SomeIcon />
-    //   <Text>Some Label</Text>
-    //   <Text slot="description">Some Description</Text>
-    // </Item>
-    content = Children.map(content, (el, i) =>
-      isElementOfType(el, Text)
-        ? cloneElement(el, {
-            ...el.props,
-            UNSAFE_className: cl(
-              el.props.UNSAFE_className,
-              stylesCommon.spectrumEllipsis
-            ),
-          })
-        : el
-    );
-  }
-  /* eslint-enable no-param-reassign */
-
-  return typeof content === 'string' || typeof content === 'number' ? (
-    <Text UNSAFE_className={stylesCommon.spectrumEllipsis}>{content}</Text>
-  ) : (
-    // eslint-disable-next-line react/jsx-no-useless-fragment
-    <>{content}</>
-  );
-}
-
-export default PickerItemContent;
diff --git a/packages/components/src/spectrum/picker/index.ts b/packages/components/src/spectrum/picker/index.ts
index b666d3021b..c434d5d810 100644
--- a/packages/components/src/spectrum/picker/index.ts
+++ b/packages/components/src/spectrum/picker/index.ts
@@ -1,2 +1 @@
 export * from './Picker';
-export * from './PickerItemContent';
diff --git a/packages/components/src/spectrum/utils/index.ts b/packages/components/src/spectrum/utils/index.ts
index ab03442699..ef406aba98 100644
--- a/packages/components/src/spectrum/utils/index.ts
+++ b/packages/components/src/spectrum/utils/index.ts
@@ -1,2 +1,4 @@
 export * from './itemUtils';
 export * from './themeUtils';
+export * from './useRenderNormalizedItem';
+export * from './useStringifiedMultiSelection';
diff --git a/packages/components/src/spectrum/utils/itemUtils.test.tsx b/packages/components/src/spectrum/utils/itemUtils.test.tsx
index e2cdad44c7..0585cfb8c8 100644
--- a/packages/components/src/spectrum/utils/itemUtils.test.tsx
+++ b/packages/components/src/spectrum/utils/itemUtils.test.tsx
@@ -14,6 +14,7 @@ import {
   ItemElementOrPrimitive,
   ItemOrSection,
   SectionElement,
+  itemSelectionToStringSet,
 } from './itemUtils';
 import type { PickerProps } from '../picker/Picker';
 import { Item, Section } from '../shared';
@@ -256,6 +257,19 @@ describe('isNormalizedSection', () => {
   });
 });
 
+describe('itemSelectionToStringSet', () => {
+  it.each([
+    ['all', 'all'],
+    [new Set([1, 2, 3]), new Set(['1', '2', '3'])],
+  ] as const)(
+    `should return 'all' or stringify the keys`,
+    (given, expected) => {
+      const actual = itemSelectionToStringSet(given);
+      expect(actual).toEqual(expected);
+    }
+  );
+});
+
 describe('normalizeItemList', () => {
   it.each([children.empty, children.single, children.mixed])(
     'should return normalized items: %#: %s',
@@ -289,4 +303,9 @@ describe('normalizeTooltipOptions', () => {
     const actual = normalizeTooltipOptions(options);
     expect(actual).toEqual(expected);
   });
+
+  it('should allow overriding default placement', () => {
+    const actual = normalizeTooltipOptions(true, 'top');
+    expect(actual).toEqual({ placement: 'top' });
+  });
 });
diff --git a/packages/components/src/spectrum/utils/itemUtils.ts b/packages/components/src/spectrum/utils/itemUtils.ts
index 06f5e852dc..351d13f94d 100644
--- a/packages/components/src/spectrum/utils/itemUtils.ts
+++ b/packages/components/src/spectrum/utils/itemUtils.ts
@@ -2,7 +2,7 @@ import { isValidElement, Key, ReactElement, ReactNode } from 'react';
 import { SpectrumPickerProps } from '@adobe/react-spectrum';
 import type { ItemRenderer } from '@react-types/shared';
 import Log from '@deephaven/log';
-import { KeyedItem } from '@deephaven/utils';
+import { KeyedItem, SelectionT } from '@deephaven/utils';
 import { Item, ItemProps, Section, SectionProps } from '../shared';
 import { PopperOptions } from '../../popper';
 
@@ -33,6 +33,8 @@ export type ItemOrSection = ItemElementOrPrimitive | SectionElement;
  */
 export type ItemKey = Key | boolean;
 
+export type ItemSelection = SelectionT<ItemKey>;
+
 /**
  * Augment the Spectrum selection change handler type to include boolean keys.
  * Spectrum components already supports this, but the built in types don't
@@ -67,6 +69,9 @@ export type NormalizedSection = KeyedItem<
   Key | undefined
 >;
 
+export type NormalizedItemOrSection<TItemOrSection extends ItemOrSection> =
+  TItemOrSection extends SectionElement ? NormalizedSection : NormalizedItem;
+
 export type NormalizedSpectrumPickerProps = SpectrumPickerProps<NormalizedItem>;
 
 export type TooltipOptions = { placement: PopperOptions['placement'] };
@@ -114,14 +119,19 @@ export function isItemElement<T>(
 }
 
 /**
- * Determine if a node is an array containing normalized items with keys.
- * Note that this only checks the first node in the array.
+ * Determine if a node is an array containing normalized items or sections with
+ * keys. Note that this only checks the first node in the array.
  * @param node The node to check
- * @returns True if the node is a normalized item with keys array
+ * @returns True if the node is a normalized item or section with keys array
  */
-export function isNormalizedItemsWithKeysList(
-  node: ItemOrSection | ItemOrSection[] | (NormalizedItem | NormalizedSection)[]
-): node is (NormalizedItem | NormalizedSection)[] {
+export function isNormalizedItemsWithKeysList<
+  TItemOrSection extends ItemOrSection,
+>(
+  node:
+    | TItemOrSection
+    | TItemOrSection[]
+    | NormalizedItemOrSection<TItemOrSection>[]
+): node is NormalizedItemOrSection<TItemOrSection>[] {
   if (!Array.isArray(node)) {
     return false;
   }
@@ -225,9 +235,9 @@ function normalizeTextValue(item: ItemElementOrPrimitive): string | undefined {
  * @param itemOrSection item to normalize
  * @returns NormalizedItem or NormalizedSection object
  */
-function normalizeItem(
-  itemOrSection: ItemOrSection
-): NormalizedItem | NormalizedSection {
+function normalizeItem<TItemOrSection extends ItemOrSection>(
+  itemOrSection: TItemOrSection
+): NormalizedItemOrSection<TItemOrSection> {
   if (!isItemOrSection(itemOrSection)) {
     log.debug(INVALID_ITEM_ERROR_MESSAGE, itemOrSection);
     throw new Error(INVALID_ITEM_ERROR_MESSAGE);
@@ -244,7 +254,7 @@ function normalizeItem(
 
     return {
       item: { key, title, items },
-    };
+    } as NormalizedItemOrSection<TItemOrSection>;
   }
 
   const key = normalizeItemKey(itemOrSection);
@@ -255,23 +265,23 @@ function normalizeItem(
 
   return {
     item: { key, content, textValue },
-  };
+  } as NormalizedItemOrSection<TItemOrSection>;
 }
 
 /**
- * Get normalized items from an item or array of items.
- * @param itemsOrSections An item or array of items
- * @returns An array of normalized items
+ * Normalize an item or section or a list of items or sections.
+ * @param itemsOrSections An item or section or array of items or sections
+ * @returns An array of normalized items or sections
  */
-export function normalizeItemList(
-  itemsOrSections: ItemOrSection | ItemOrSection[] | NormalizedItem[]
-): (NormalizedItem | NormalizedSection)[] {
+export function normalizeItemList<TItemOrSection extends ItemOrSection>(
+  itemsOrSections: TItemOrSection | TItemOrSection[] | NormalizedItem[]
+): NormalizedItemOrSection<TItemOrSection>[] {
   // If already normalized, just return as-is
   if (isNormalizedItemsWithKeysList(itemsOrSections)) {
-    return itemsOrSections;
+    return itemsOrSections as NormalizedItemOrSection<TItemOrSection>[];
   }
 
-  const itemsArray = Array.isArray(itemsOrSections)
+  const itemsArray: TItemOrSection[] = Array.isArray(itemsOrSections)
     ? itemsOrSections
     : [itemsOrSections];
 
@@ -280,19 +290,37 @@ export function normalizeItemList(
 
 /**
  * Returns a TooltipOptions object or null if options is false or null.
- * @param options
+ * @param options Tooltip options
+ * @param placement Default placement for the tooltip if `options` is set
+ * explicitly to `true`
  * @returns TooltipOptions or null
  */
 export function normalizeTooltipOptions(
-  options?: boolean | TooltipOptions | null
+  options?: boolean | TooltipOptions | null,
+  placement: TooltipOptions['placement'] = 'right'
 ): TooltipOptions | null {
   if (options == null || options === false) {
     return null;
   }
 
   if (options === true) {
-    return { placement: 'right' };
+    return { placement };
   }
 
   return options;
 }
+
+/**
+ * Convert a selection of `ItemKey`s to a selection of strings.
+ * @param itemKeys The selection of `ItemKey`s
+ * @returns The selection of strings
+ */
+export function itemSelectionToStringSet(
+  itemKeys?: 'all' | Iterable<ItemKey>
+): undefined | 'all' | Set<string> {
+  if (itemKeys == null || itemKeys === 'all') {
+    return itemKeys as undefined | 'all';
+  }
+
+  return new Set([...itemKeys].map(String));
+}
diff --git a/packages/components/src/spectrum/utils/useRenderNormalizedItem.test.tsx b/packages/components/src/spectrum/utils/useRenderNormalizedItem.test.tsx
new file mode 100644
index 0000000000..993524b71a
--- /dev/null
+++ b/packages/components/src/spectrum/utils/useRenderNormalizedItem.test.tsx
@@ -0,0 +1,41 @@
+import React, { Key } from 'react';
+import { Item } from '@adobe/react-spectrum';
+import { renderHook } from '@testing-library/react-hooks';
+import ItemContent from '../ItemContent';
+import { useRenderNormalizedItem } from './useRenderNormalizedItem';
+import { getItemKey } from './itemUtils';
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  expect.hasAssertions();
+});
+
+describe.each([null, { placement: 'top' }] as const)(
+  'useRenderNormalizedItem: %s',
+  tooltipOptions => {
+    it.each([
+      [{}, 'Empty', ''],
+      [{ item: { content: 'mock.content' } }, 'Empty', 'mock.content'],
+      [
+        { item: { textValue: 'mock.textValue', content: 'mock.content' } },
+        'mock.textValue',
+        'mock.content',
+      ],
+    ])(
+      'should return a render function that can be used to render a normalized item in collection components.',
+      (normalizedItem, textValue, content) => {
+        const { result } = renderHook(() =>
+          useRenderNormalizedItem(tooltipOptions)
+        );
+
+        const actual = result.current(normalizedItem);
+
+        expect(actual).toEqual(
+          <Item key={getItemKey(normalizedItem) as Key} textValue={textValue}>
+            <ItemContent tooltipOptions={tooltipOptions}>{content}</ItemContent>
+          </Item>
+        );
+      }
+    );
+  }
+);
diff --git a/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx b/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx
new file mode 100644
index 0000000000..2904bbb558
--- /dev/null
+++ b/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx
@@ -0,0 +1,45 @@
+import { Key, useCallback } from 'react';
+import { ItemContent } from '../ItemContent';
+import { Item } from '../shared';
+import { getItemKey, NormalizedItem, TooltipOptions } from './itemUtils';
+
+/**
+ * Returns a render function that can be used to render a normalized item in
+ * collection components.
+ * @param tooltipOptions Tooltip options to use when rendering the item
+ * @returns Render function for normalized items
+ */
+export function useRenderNormalizedItem(
+  tooltipOptions: TooltipOptions | null
+): (normalizedItem: NormalizedItem) => JSX.Element {
+  return useCallback(
+    (normalizedItem: NormalizedItem) => {
+      const key = getItemKey(normalizedItem);
+      const content = normalizedItem.item?.content ?? '';
+      const textValue = normalizedItem.item?.textValue ?? '';
+
+      return (
+        <Item
+          // Note that setting the `key` prop explicitly on `Item` elements
+          // causes the picker to expect `selectedKey` and `defaultSelectedKey`
+          // to be strings. It also passes the stringified value of the key to
+          // `onSelectionChange` handlers` regardless of the actual type of the
+          // key. We can't really get around setting in order to support Windowed
+          // data, so we'll need to do some manual conversion of keys to strings
+          // in other components that use this hook.
+          key={key as Key}
+          // The `textValue` prop gets used to provide the content of `<option>`
+          // elements that back the Spectrum Picker. These are not visible in the UI,
+          // but are used for accessibility purposes, so we set to an arbitrary
+          // 'Empty' value so that they are not empty strings.
+          textValue={textValue === '' ? 'Empty' : textValue}
+        >
+          <ItemContent tooltipOptions={tooltipOptions}>{content}</ItemContent>
+        </Item>
+      );
+    },
+    [tooltipOptions]
+  );
+}
+
+export default useRenderNormalizedItem;
diff --git a/packages/components/src/spectrum/utils/useStringifiedMultiSelection.test.ts b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.test.ts
new file mode 100644
index 0000000000..a429d9c1e7
--- /dev/null
+++ b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.test.ts
@@ -0,0 +1,67 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { NormalizedItem } from './itemUtils';
+import { useStringifiedMultiSelection } from './useStringifiedMultiSelection';
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  expect.hasAssertions();
+});
+
+describe('useStringifiedMultiSelection', () => {
+  const normalizedItems: NormalizedItem[] = [1, 2, 3, 4, 5, 6, 7, 8, 9].map(
+    i => ({
+      key: i,
+      item: { key: i, content: `Item ${i}` },
+    })
+  );
+
+  const selectedKeys = [1, 2, 3];
+  const defaultSelectedKeys = [4, 5, 6];
+  const disabledKeys = [7, 8, 9];
+
+  const selectedStringKeys = new Set(['1', '2', '3']);
+  const defaultSelectedStringKeys = new Set(['4', '5', '6']);
+  const disabledStringKeys = new Set(['7', '8', '9']);
+
+  it('should stringify selections', () => {
+    const { result } = renderHook(() =>
+      useStringifiedMultiSelection({
+        normalizedItems,
+        selectedKeys,
+        defaultSelectedKeys,
+        disabledKeys,
+      })
+    );
+
+    expect(result.current).toEqual({
+      selectedStringKeys,
+      defaultSelectedStringKeys,
+      disabledStringKeys,
+      onStringSelectionChange: expect.any(Function),
+    });
+  });
+
+  it.each([
+    ['all', 'all'],
+    [new Set(['1', '2', '3']), new Set([1, 2, 3])],
+  ] as const)(
+    `should call onChange with 'all' or actual keys`,
+    (given, expected) => {
+      const onChange = jest.fn().mockName('onChange');
+
+      const { result } = renderHook(() =>
+        useStringifiedMultiSelection({
+          normalizedItems,
+          selectedKeys,
+          defaultSelectedKeys,
+          disabledKeys,
+          onChange,
+        })
+      );
+
+      result.current.onStringSelectionChange(given);
+
+      expect(onChange).toHaveBeenCalledWith(expected);
+    }
+  );
+});
diff --git a/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts
new file mode 100644
index 0000000000..04ec670168
--- /dev/null
+++ b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts
@@ -0,0 +1,100 @@
+import { Key, useCallback, useMemo } from 'react';
+import {
+  getItemKey,
+  ItemKey,
+  ItemSelection,
+  itemSelectionToStringSet,
+  NormalizedItem,
+} from './itemUtils';
+
+export interface UseStringifiedMultiSelectionOptions {
+  normalizedItems: NormalizedItem[];
+  selectedKeys?: 'all' | Iterable<ItemKey>;
+  defaultSelectedKeys?: 'all' | Iterable<ItemKey>;
+  disabledKeys?: Iterable<ItemKey>;
+  /**
+   * Handler that is called when the selection change.
+   * Note that under the hood, this is just an alias for Spectrum's
+   * `onSelectionChange`. We are renaming for better consistency with other
+   * components.
+   */
+  onChange?: (keys: ItemSelection) => void;
+}
+
+export interface UseStringifiedMultiSelectionResult {
+  /** Stringified selection keys */
+  selectedStringKeys?: 'all' | Set<Key>;
+  /** Stringified default selection keys */
+  defaultSelectedStringKeys?: 'all' | Set<Key>;
+  /** Stringified disabled keys */
+  disabledStringKeys?: 'all' | Set<Key>;
+  /** Handler that is called when the string key selections change */
+  onStringSelectionChange: (keys: 'all' | Set<Key>) => void;
+}
+
+/**
+ * Spectrum collection components treat keys as strings if the `key` prop is
+ * explicitly set on `Item` elements. Since we do this in `useRenderNormalizedItem`,
+ * we need to ensure that keys are strings in order for selection to work. We
+ * then need to convert back to the original key types in the onChange handler.
+ * This hook encapsulates converting to and from strings so that keys can match
+ * the original key type.
+ * @param normalizedItems The normalized items to select from.
+ * @param selectedKeys The currently selected keys in the collection.
+ * @param defaultSelectedKeys The initial selected keys in the collection.
+ * @param disabledKeys The currently disabled keys in the collection.
+ * @param onChange Handler that is called when the selection changes.
+ * @returns UseStringifiedMultiSelectionResult with stringified key sets and
+ * string key selection change handler.
+ */
+export function useStringifiedMultiSelection({
+  normalizedItems,
+  defaultSelectedKeys,
+  disabledKeys,
+  selectedKeys,
+  onChange,
+}: UseStringifiedMultiSelectionOptions): UseStringifiedMultiSelectionResult {
+  const selectedStringKeys = useMemo(
+    () => itemSelectionToStringSet(selectedKeys),
+    [selectedKeys]
+  );
+
+  const defaultSelectedStringKeys = useMemo(
+    () => itemSelectionToStringSet(defaultSelectedKeys),
+    [defaultSelectedKeys]
+  );
+
+  const disabledStringKeys = useMemo(
+    () => itemSelectionToStringSet(disabledKeys),
+    [disabledKeys]
+  );
+
+  const onStringSelectionChange = useCallback(
+    (keys: 'all' | Set<Key>) => {
+      if (keys === 'all') {
+        onChange?.('all');
+        return;
+      }
+
+      const actualKeys = new Set<ItemKey>();
+
+      normalizedItems.forEach(item => {
+        if (keys.has(String(getItemKey(item)))) {
+          actualKeys.add(getItemKey(item));
+        }
+      });
+
+      onChange?.(actualKeys);
+    },
+    [normalizedItems, onChange]
+  );
+
+  return {
+    selectedStringKeys,
+    defaultSelectedStringKeys,
+    disabledStringKeys,
+    onStringSelectionChange,
+  };
+}
+
+export default useStringifiedMultiSelection;
diff --git a/packages/components/src/theme/index.ts b/packages/components/src/theme/index.ts
index 523fd23ddd..f58f84d25b 100644
--- a/packages/components/src/theme/index.ts
+++ b/packages/components/src/theme/index.ts
@@ -7,3 +7,4 @@ export * from './ThemeUtils';
 export * from './useTheme';
 export * from './Logo';
 export * from './colorUtils';
+export * from './useSpectrumThemeProvider';
diff --git a/packages/components/src/theme/useSpectrumThemeProvider.ts b/packages/components/src/theme/useSpectrumThemeProvider.ts
new file mode 100644
index 0000000000..ee6bb35952
--- /dev/null
+++ b/packages/components/src/theme/useSpectrumThemeProvider.ts
@@ -0,0 +1,5 @@
+import { useProvider } from '@adobe/react-spectrum';
+
+export const useSpectrumThemeProvider = useProvider;
+
+export default useSpectrumThemeProvider;
diff --git a/packages/jsapi-components/src/spectrum/ListView.tsx b/packages/jsapi-components/src/spectrum/ListView.tsx
new file mode 100644
index 0000000000..fd363c896a
--- /dev/null
+++ b/packages/jsapi-components/src/spectrum/ListView.tsx
@@ -0,0 +1,66 @@
+import {
+  ListView as ListViewBase,
+  ListViewProps as ListViewPropsBase,
+  NormalizedItemData,
+  useSpectrumThemeProvider,
+} from '@deephaven/components';
+import { dh as DhType } from '@deephaven/jsapi-types';
+import { Settings } from '@deephaven/jsapi-utils';
+import { LIST_VIEW_ROW_HEIGHTS } from '@deephaven/utils';
+import useFormatter from '../useFormatter';
+import useViewportData from '../useViewportData';
+import { useItemRowDeserializer } from './utils';
+
+export interface ListViewProps extends Omit<ListViewPropsBase, 'children'> {
+  table: DhType.Table;
+  /* The column of values to use as item keys. Defaults to the first column. */
+  keyColumn?: string;
+  /* The column of values to display as primary text. Defaults to the `keyColumn` value. */
+  labelColumn?: string;
+
+  // TODO #1890 : descriptionColumn, iconColumn
+
+  settings?: Settings;
+}
+
+export function ListView({
+  table,
+  keyColumn: keyColumnName,
+  labelColumn: labelColumnName,
+  settings,
+  ...props
+}: ListViewProps): JSX.Element {
+  const { scale } = useSpectrumThemeProvider();
+  const itemHeight = LIST_VIEW_ROW_HEIGHTS[props.density ?? 'regular'][scale];
+
+  const { getFormattedString: formatValue } = useFormatter(settings);
+
+  const deserializeRow = useItemRowDeserializer({
+    table,
+    keyColumnName,
+    labelColumnName,
+    formatValue,
+  });
+
+  const { viewportData, onScroll } = useViewportData<
+    NormalizedItemData,
+    DhType.Table
+  >({
+    reuseItemsOnTableResize: true,
+    table,
+    itemHeight,
+    deserializeRow,
+  });
+
+  return (
+    <ListViewBase
+      // eslint-disable-next-line react/jsx-props-no-spreading
+      {...props}
+      onScroll={onScroll}
+    >
+      {viewportData.items}
+    </ListViewBase>
+  );
+}
+
+export default ListView;
diff --git a/packages/jsapi-components/src/spectrum/index.ts b/packages/jsapi-components/src/spectrum/index.ts
index c434d5d810..49fd5a7e25 100644
--- a/packages/jsapi-components/src/spectrum/index.ts
+++ b/packages/jsapi-components/src/spectrum/index.ts
@@ -1 +1,2 @@
+export * from './ListView';
 export * from './Picker';
diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts
index 1555908b88..a3d98b2498 100644
--- a/packages/react-hooks/src/index.ts
+++ b/packages/react-hooks/src/index.ts
@@ -3,6 +3,8 @@ export * from './SelectionUtils';
 export * from './SpectrumUtils';
 export * from './useAsyncInterval';
 export * from './useCallbackWithAction';
+export * from './useCheckOverflow';
+export * from './useContentRect';
 export { default as useContextOrThrow } from './useContextOrThrow';
 export * from './useDebouncedCallback';
 export * from './useDelay';
diff --git a/packages/react-hooks/src/useCheckOverflow.test.ts b/packages/react-hooks/src/useCheckOverflow.test.ts
new file mode 100644
index 0000000000..9499e91889
--- /dev/null
+++ b/packages/react-hooks/src/useCheckOverflow.test.ts
@@ -0,0 +1,59 @@
+import { act, renderHook } from '@testing-library/react-hooks';
+import type { DOMRefValue } from '@react-types/shared';
+import { TestUtils } from '@deephaven/utils';
+import { useCheckOverflow } from './useCheckOverflow';
+
+const { createMockProxy } = TestUtils;
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  expect.hasAssertions();
+});
+
+describe('useCheckOverflow', () => {
+  const isOverflowing = createMockProxy<HTMLDivElement>({
+    scrollWidth: 101,
+    offsetWidth: 100,
+  });
+
+  const scrollWidthMatchesOffsetWidth = createMockProxy<HTMLDivElement>({
+    scrollWidth: 100,
+    offsetWidth: 100,
+  });
+
+  const offsetWidthGreaterThanScrollWidth = createMockProxy<HTMLDivElement>({
+    scrollWidth: 99,
+    offsetWidth: 100,
+  });
+
+  it.each([
+    [null, false],
+    [isOverflowing, true],
+    [scrollWidthMatchesOffsetWidth, false],
+    [offsetWidthGreaterThanScrollWidth, false],
+  ])(
+    'should check if a Spectrum `DOMRefValue` is overflowing: %s, %s',
+    (el, expected) => {
+      const { result } = renderHook(() => useCheckOverflow());
+
+      const elRef =
+        el == null
+          ? null
+          : createMockProxy<DOMRefValue<HTMLDivElement>>({
+              UNSAFE_getDOMNode: () => createMockProxy<HTMLDivElement>(el),
+            });
+
+      act(() => {
+        result.current.checkOverflow(elRef);
+      });
+
+      expect(result.current.isOverflowing).toBe(expected);
+
+      act(() => {
+        result.current.resetIsOverflowing();
+      });
+
+      expect(result.current.isOverflowing).toBe(false);
+    }
+  );
+});
diff --git a/packages/react-hooks/src/useCheckOverflow.ts b/packages/react-hooks/src/useCheckOverflow.ts
new file mode 100644
index 0000000000..9b33c3eb8e
--- /dev/null
+++ b/packages/react-hooks/src/useCheckOverflow.ts
@@ -0,0 +1,64 @@
+import { useCallback, useState } from 'react';
+import type { DOMRefValue } from '@react-types/shared';
+
+export interface CheckOverflowResult {
+  /**
+   * Callback to check if a Spectrum `DOMRefValue` is overflowing. If an
+   * overflowing value is passed, `isOverflowing` will be set to true. Note that
+   * calling again with a non-overflowing value will *not* reset the state.
+   * Instead `resetIsOverflowing` must be called explicitly. This is to allow
+   * multiple `DOMRefValue`s to be checked and `isOverflowing` to remain `true`
+   * if at least one of them is overflowing.
+   */
+  checkOverflow: <T extends HTMLElement>(elRef: DOMRefValue<T> | null) => void;
+
+  /**
+   * Will be set to true whenever `checkOverflow` is called with an overflowing
+   * `DOMRefValue`. It will remain `true` until `resetIsOverflowing` is called.
+   * Default state is `false`.
+   */
+  isOverflowing: boolean;
+
+  /** Reset `isOverflowing` to false */
+  resetIsOverflowing: () => void;
+}
+
+/**
+ * Provides a callback to check a Spectrum `DOMRefValue` for overflow. If
+ * overflow is detected, `isOverflowing` will be set to `true` until reset by
+ * calling `resetIsOverflowing`.
+ */
+export function useCheckOverflow(): CheckOverflowResult {
+  const [isOverflowing, setIsOverflowing] = useState(false);
+
+  /**
+   * Check if a Spectrum `DOMRefValue` is overflowing.
+   */
+  const checkOverflow = useCallback(
+    <T extends HTMLElement>(elRef: DOMRefValue<T> | null) => {
+      const el = elRef?.UNSAFE_getDOMNode();
+
+      if (el == null) {
+        return;
+      }
+
+      if (el.scrollWidth > el.offsetWidth) {
+        setIsOverflowing(true);
+      }
+    },
+    []
+  );
+
+  /** Reset `isOverflowing` to false */
+  const resetIsOverflowing = useCallback(() => {
+    setIsOverflowing(false);
+  }, []);
+
+  return {
+    isOverflowing,
+    checkOverflow,
+    resetIsOverflowing,
+  };
+}
+
+export default useCheckOverflow;
diff --git a/packages/react-hooks/src/useContentRect.test.ts b/packages/react-hooks/src/useContentRect.test.ts
new file mode 100644
index 0000000000..42e248b822
--- /dev/null
+++ b/packages/react-hooks/src/useContentRect.test.ts
@@ -0,0 +1,72 @@
+import { act, renderHook } from '@testing-library/react-hooks';
+import { TestUtils } from '@deephaven/utils';
+import { useContentRect } from './useContentRect';
+import useResizeObserver from './useResizeObserver';
+
+jest.mock('./useResizeObserver');
+
+const { asMock, createMockProxy } = TestUtils;
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  expect.hasAssertions();
+  asMock(useResizeObserver).mockName('useResizeObserver');
+});
+
+describe.each([true, false])('useContentRect - explicitMap:%s', explicitMap => {
+  const mock = {
+    refValue: document.createElement('div'),
+    mappedValue: document.createElement('span'),
+    resizeEntry: createMockProxy<ResizeObserverEntry>({
+      contentRect: new DOMRect(0, 0, 100, 100),
+    }),
+    observer: createMockProxy<ResizeObserver>(),
+  };
+
+  const mockMap = explicitMap ? jest.fn(() => mock.mappedValue) : undefined;
+
+  it('should initially return zero size contentRect', () => {
+    const { result } = renderHook(() => useContentRect(mockMap));
+    expect(useResizeObserver).toHaveBeenCalledWith(null, expect.any(Function));
+    expect(result.current.contentRect).toEqual(new DOMRect());
+  });
+
+  it('should pass expected value to resize observer based on presence of map function', () => {
+    const { result } = renderHook(() => useContentRect(mockMap));
+
+    act(() => {
+      result.current.ref(mock.refValue);
+    });
+
+    if (mockMap != null) {
+      expect(mockMap).toHaveBeenCalledWith(mock.refValue);
+    }
+    expect(useResizeObserver).toHaveBeenCalledWith(
+      mockMap == null ? mock.refValue : mock.mappedValue,
+      expect.any(Function)
+    );
+    expect(result.current.contentRect).toEqual(new DOMRect());
+  });
+
+  it.each([
+    [[], new DOMRect()],
+    [[mock.resizeEntry], mock.resizeEntry.contentRect],
+  ])(
+    'should update contentRect when resize observer triggers: %s',
+    (entries, expected) => {
+      const { result } = renderHook(() => useContentRect(mockMap));
+
+      act(() => {
+        result.current.ref(mock.refValue);
+      });
+
+      const handleResize = asMock(useResizeObserver).mock.calls.at(-1)?.[1];
+
+      act(() => {
+        handleResize?.(entries, mock.observer);
+      });
+
+      expect(result.current.contentRect).toEqual(expected);
+    }
+  );
+});
diff --git a/packages/react-hooks/src/useContentRect.ts b/packages/react-hooks/src/useContentRect.ts
new file mode 100644
index 0000000000..3725cc9da1
--- /dev/null
+++ b/packages/react-hooks/src/useContentRect.ts
@@ -0,0 +1,62 @@
+import { identityExtractHTMLElement } from '@deephaven/utils';
+import { useCallback, useMemo, useState } from 'react';
+import useMappedRef from './useMappedRef';
+import useResizeObserver from './useResizeObserver';
+
+export interface UseContentRectResult<T> {
+  contentRect: DOMRectReadOnly;
+  ref: (refValue: T) => void;
+}
+
+/**
+ * Returns a callback ref that will track the `contentRect` of a given refValue.
+ * If the `contentRect` is undefined, it will be set to a new `DOMRect` with
+ * zeros for all dimensions.
+ * @param map Optional mapping function to extract an HTMLElement from the given
+ * refValue
+ * @returns Content rect and a ref callback
+ */
+export function useContentRect<T>(
+  map: (ref: T) => HTMLElement | null = identityExtractHTMLElement
+): UseContentRectResult<T> {
+  const [x, setX] = useState<number>(0);
+  const [y, setY] = useState<number>(0);
+  const [width, setWidth] = useState<number>(0);
+  const [height, setHeight] = useState<number>(0);
+
+  const contentRect = useMemo(
+    () => new DOMRect(x, y, width, height),
+    [height, width, x, y]
+  );
+
+  const [el, setEl] = useState<HTMLElement | null>(null);
+
+  // Callback ref maps the passed in refValue and passes to `setEl`
+  const ref = useMappedRef(setEl, map);
+
+  const handleResize = useCallback(
+    ([firstEntry]: ResizeObserverEntry[]): void => {
+      const rect = firstEntry?.contentRect ?? {
+        x: 0,
+        y: 0,
+        width: 0,
+        height: 0,
+      };
+
+      setX(rect.x);
+      setY(rect.y);
+      setWidth(rect.width);
+      setHeight(rect.height);
+    },
+    []
+  );
+
+  useResizeObserver(el, handleResize);
+
+  return {
+    ref,
+    contentRect,
+  };
+}
+
+export default useContentRect;
diff --git a/packages/utils/src/UIConstants.ts b/packages/utils/src/UIConstants.ts
index 3be0c189ef..a7cf22fabf 100644
--- a/packages/utils/src/UIConstants.ts
+++ b/packages/utils/src/UIConstants.ts
@@ -2,7 +2,6 @@ export const ACTION_ICON_HEIGHT = 24;
 export const COMBO_BOX_ITEM_HEIGHT = 32;
 export const COMBO_BOX_TOP_OFFSET = 4;
 export const ITEM_KEY_PREFIX = 'DH_ITEM_KEY';
-export const LIST_VIEW_ROW_HEIGHT = 32;
 export const PICKER_ITEM_HEIGHT = 32;
 export const PICKER_TOP_OFFSET = 4;
 export const TABLE_ROW_HEIGHT = 33;
@@ -12,3 +11,19 @@ export const VIEWPORT_SIZE = 500;
 export const VIEWPORT_PADDING = 250;
 
 export const SPELLCHECK_FALSE_ATTRIBUTE = { spellCheck: false } as const;
+
+// Copied from https://github.com/adobe/react-spectrum/blob/b2d25ef23b827ec2427bf47b343e6dbd66326ed3/packages/%40react-spectrum/list/src/ListView.tsx#L78
+export const LIST_VIEW_ROW_HEIGHTS = {
+  compact: {
+    medium: 32,
+    large: 40,
+  },
+  regular: {
+    medium: 40,
+    large: 50,
+  },
+  spacious: {
+    medium: 48,
+    large: 60,
+  },
+} as const;
diff --git a/tests/styleguide.spec.ts b/tests/styleguide.spec.ts
index 0d6cf06304..5c15fe69ee 100644
--- a/tests/styleguide.spec.ts
+++ b/tests/styleguide.spec.ts
@@ -26,6 +26,7 @@ const sampleSectionIds: string[] = [
   'sample-section-context-menus',
   'sample-section-dropdown-menus',
   'sample-section-navigations',
+  'sample-section-list-views',
   'sample-section-pickers',
   'sample-section-tooltips',
   'sample-section-icons',
diff --git a/tests/styleguide.spec.ts-snapshots/list-views-chromium-linux.png b/tests/styleguide.spec.ts-snapshots/list-views-chromium-linux.png
new file mode 100644
index 0000000000..020ed77f30
Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/list-views-chromium-linux.png differ
diff --git a/tests/styleguide.spec.ts-snapshots/list-views-firefox-linux.png b/tests/styleguide.spec.ts-snapshots/list-views-firefox-linux.png
new file mode 100644
index 0000000000..25e11db072
Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/list-views-firefox-linux.png differ
diff --git a/tests/styleguide.spec.ts-snapshots/list-views-webkit-linux.png b/tests/styleguide.spec.ts-snapshots/list-views-webkit-linux.png
new file mode 100644
index 0000000000..81be65cac8
Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/list-views-webkit-linux.png differ