diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts
index 44ae7793..c0ba0d73 100644
--- a/.storybook/components/Roadmap/data.ts
+++ b/.storybook/components/Roadmap/data.ts
@@ -434,7 +434,8 @@ export const rows: Rows = [
},
{
component: 'ClampedList',
- status: '🚧 Planned',
+ status: '✅ Done',
+ stage: '🔵 experimental',
planned: 'Q3 2026',
},
{
diff --git a/packages/components/src/components/ClampedList/ClampedList.mdx b/packages/components/src/components/ClampedList/ClampedList.mdx
new file mode 100644
index 00000000..b9eea85b
--- /dev/null
+++ b/packages/components/src/components/ClampedList/ClampedList.mdx
@@ -0,0 +1,73 @@
+import {
+ Meta,
+ Story,
+ Props,
+ Status,
+ Alert,
+} from '../../../../../.storybook/components';
+
+import * as Stories from './ClampedList.stories';
+
+
+
+# ClampedList
+
+
+
+`ClampedList` keeps long collections compact and lets users show or hide the remaining items.
+It provides the visible items, and you define how to render them.
+
+## Import
+
+```tsx
+import { ClampedList } from '@koobiq/react-components';
+```
+
+## Usage
+
+
+
+## Props
+
+
+
+## Visibility threshold
+
+By default, `ClampedList` shows `10` items. The toggle appears when `6` or more
+items remain. If fewer items remain, all items are shown.
+
+Use `collapsedVisibleCount` and `hiddenThreshold` to change these values.
+`hiddenItemCount` is based on the collapsed state, so it stays the same after
+expansion.
+
+
+
+## Controlled expansion
+
+Use `isExpanded` with `onExpandedChange` when expansion state is owned by the
+application. For an uncontrolled initial state, use `defaultExpanded`.
+
+
+
+## Flat lists
+
+Items can be rendered inline. Use `moreText` and `lessText` for compact toggle
+labels that continue the same line as the visible items.
+
+Use `slotProps.content` and `slotProps.toggle` to customize the content
+container and toggle. Set `slotProps.toggle.icon` to `null` to remove the
+chevron. Pass an icon or a function that receives `isExpanded` to replace it.
+
+
+
+Use a centered dot when a stronger visual separator is needed in a wrapped
+multiline list.
+
+
+
+## Accessibility
+
+The rendered collection is wrapped in a `role="group"` container. The native
+button references that container with `aria-controls` and exposes the current
+state through `aria-expanded`. It supports mouse, touch, Enter, and Space
+interactions.
diff --git a/packages/components/src/components/ClampedList/ClampedList.module.css b/packages/components/src/components/ClampedList/ClampedList.module.css
new file mode 100644
index 00000000..dd8c983b
--- /dev/null
+++ b/packages/components/src/components/ClampedList/ClampedList.module.css
@@ -0,0 +1,7 @@
+.base {
+ max-inline-size: 100%;
+}
+
+.content {
+ min-inline-size: 0;
+}
diff --git a/packages/components/src/components/ClampedList/ClampedList.stories.tsx b/packages/components/src/components/ClampedList/ClampedList.stories.tsx
new file mode 100644
index 00000000..ed96e9c7
--- /dev/null
+++ b/packages/components/src/components/ClampedList/ClampedList.stories.tsx
@@ -0,0 +1,576 @@
+import { useState } from 'react';
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { FlexBox } from '../FlexBox';
+import { Link } from '../Link';
+import { Toggle } from '../Toggle';
+import { Typography } from '../Typography';
+
+import { ClampedList, type ClampedListProps } from './index';
+
+type Item = {
+ id: number | string;
+ name: string;
+ url: string;
+};
+
+const meta = {
+ title: 'Components/ClampedList',
+ component: ClampedList,
+ parameters: {
+ layout: 'padded',
+ },
+ tags: ['status:new', 'date:2026-08-14'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj>;
+
+export const Base: Story = {
+ render: function Render(args) {
+ const items = [
+ {
+ id: 'T1557',
+ name: 'Adversary-in-the-Middle',
+ url: 'https://attack.mitre.org/techniques/T1557/',
+ },
+ {
+ id: 'T1110',
+ name: 'Brute Force',
+ url: 'https://attack.mitre.org/techniques/T1110/',
+ },
+ {
+ id: 'T1555',
+ name: 'Credentials from Password Stores',
+ url: 'https://attack.mitre.org/techniques/T1555/',
+ },
+ {
+ id: 'T1212',
+ name: 'Exploitation for Credential Access',
+ url: 'https://attack.mitre.org/techniques/T1212/',
+ },
+ {
+ id: 'T1187',
+ name: 'Forced Authentication',
+ url: 'https://attack.mitre.org/techniques/T1187/',
+ },
+ {
+ id: 'T1606',
+ name: 'Forge Web Credentials',
+ url: 'https://attack.mitre.org/techniques/T1606/',
+ },
+ {
+ id: 'T1056',
+ name: 'Input Capture',
+ url: 'https://attack.mitre.org/techniques/T1056/',
+ },
+ {
+ id: 'T1556',
+ name: 'Modify Authentication Process',
+ url: 'https://attack.mitre.org/techniques/T1556/',
+ },
+ {
+ id: 'T1111',
+ name: 'Multi-Factor Authentication Interception',
+ url: 'https://attack.mitre.org/techniques/T1111/',
+ },
+ {
+ id: 'T1621',
+ name: 'Multi-Factor Authentication Request Generation',
+ url: 'https://attack.mitre.org/techniques/T1621/',
+ },
+ {
+ id: 'T1040',
+ name: 'Network Sniffing',
+ url: 'https://attack.mitre.org/techniques/T1040/',
+ },
+ {
+ id: 'T1003',
+ name: 'OS Credential Dumping',
+ url: 'https://attack.mitre.org/techniques/T1003/',
+ },
+ {
+ id: 'T1528',
+ name: 'Steal Application Access Token',
+ url: 'https://attack.mitre.org/techniques/T1528/',
+ },
+ {
+ id: 'T1649',
+ name: 'Steal or Forge Authentication Certificates',
+ url: 'https://attack.mitre.org/techniques/T1649/',
+ },
+ {
+ id: 'T1558',
+ name: 'Steal or Forge Kerberos Tickets',
+ url: 'https://attack.mitre.org/techniques/T1558/',
+ },
+ {
+ id: 'T1539',
+ name: 'Steal Web Session Cookie',
+ url: 'https://attack.mitre.org/techniques/T1539/',
+ },
+ {
+ id: 'T1552',
+ name: 'Unsecured Credentials',
+ url: 'https://attack.mitre.org/techniques/T1552/',
+ },
+ ];
+
+ return (
+
+ {({ visibleItems }) => (
+
+ {visibleItems.map((item) => (
+ -
+
+ {item.name}
+
+ {item.id}
+
+ ))}
+
+ )}
+
+ );
+ },
+};
+
+export const CustomVisibility: Story = {
+ render: function Render() {
+ const items = [
+ {
+ id: 'T1557',
+ name: 'Adversary-in-the-Middle',
+ url: 'https://attack.mitre.org/techniques/T1557/',
+ },
+ {
+ id: 'T1110',
+ name: 'Brute Force',
+ url: 'https://attack.mitre.org/techniques/T1110/',
+ },
+ {
+ id: 'T1555',
+ name: 'Credentials from Password Stores',
+ url: 'https://attack.mitre.org/techniques/T1555/',
+ },
+ {
+ id: 'T1212',
+ name: 'Exploitation for Credential Access',
+ url: 'https://attack.mitre.org/techniques/T1212/',
+ },
+ {
+ id: 'T1187',
+ name: 'Forced Authentication',
+ url: 'https://attack.mitre.org/techniques/T1187/',
+ },
+ {
+ id: 'T1606',
+ name: 'Forge Web Credentials',
+ url: 'https://attack.mitre.org/techniques/T1606/',
+ },
+ {
+ id: 'T1056',
+ name: 'Input Capture',
+ url: 'https://attack.mitre.org/techniques/T1056/',
+ },
+ {
+ id: 'T1556',
+ name: 'Modify Authentication Process',
+ url: 'https://attack.mitre.org/techniques/T1556/',
+ },
+ {
+ id: 'T1111',
+ name: 'Multi-Factor Authentication Interception',
+ url: 'https://attack.mitre.org/techniques/T1111/',
+ },
+ {
+ id: 'T1621',
+ name: 'Multi-Factor Authentication Request Generation',
+ url: 'https://attack.mitre.org/techniques/T1621/',
+ },
+ {
+ id: 'T1040',
+ name: 'Network Sniffing',
+ url: 'https://attack.mitre.org/techniques/T1040/',
+ },
+ {
+ id: 'T1003',
+ name: 'OS Credential Dumping',
+ url: 'https://attack.mitre.org/techniques/T1003/',
+ },
+ {
+ id: 'T1528',
+ name: 'Steal Application Access Token',
+ url: 'https://attack.mitre.org/techniques/T1528/',
+ },
+ {
+ id: 'T1649',
+ name: 'Steal or Forge Authentication Certificates',
+ url: 'https://attack.mitre.org/techniques/T1649/',
+ },
+ {
+ id: 'T1558',
+ name: 'Steal or Forge Kerberos Tickets',
+ url: 'https://attack.mitre.org/techniques/T1558/',
+ },
+ {
+ id: 'T1539',
+ name: 'Steal Web Session Cookie',
+ url: 'https://attack.mitre.org/techniques/T1539/',
+ },
+ {
+ id: 'T1552',
+ name: 'Unsecured Credentials',
+ url: 'https://attack.mitre.org/techniques/T1552/',
+ },
+ ];
+
+ return (
+
+ {({ visibleItems }) => (
+
+ {visibleItems.map((item) => (
+ -
+
+ {item.name}
+
+ {item.id}
+
+ ))}
+
+ )}
+
+ );
+ },
+};
+
+export const Controlled: Story = {
+ render: function Render() {
+ const [isExpanded, setExpanded] = useState(false);
+
+ const items = [
+ {
+ id: 'T1557',
+ name: 'Adversary-in-the-Middle',
+ url: 'https://attack.mitre.org/techniques/T1557/',
+ },
+ {
+ id: 'T1110',
+ name: 'Brute Force',
+ url: 'https://attack.mitre.org/techniques/T1110/',
+ },
+ {
+ id: 'T1555',
+ name: 'Credentials from Password Stores',
+ url: 'https://attack.mitre.org/techniques/T1555/',
+ },
+ {
+ id: 'T1212',
+ name: 'Exploitation for Credential Access',
+ url: 'https://attack.mitre.org/techniques/T1212/',
+ },
+ {
+ id: 'T1187',
+ name: 'Forced Authentication',
+ url: 'https://attack.mitre.org/techniques/T1187/',
+ },
+ {
+ id: 'T1606',
+ name: 'Forge Web Credentials',
+ url: 'https://attack.mitre.org/techniques/T1606/',
+ },
+ {
+ id: 'T1056',
+ name: 'Input Capture',
+ url: 'https://attack.mitre.org/techniques/T1056/',
+ },
+ {
+ id: 'T1556',
+ name: 'Modify Authentication Process',
+ url: 'https://attack.mitre.org/techniques/T1556/',
+ },
+ {
+ id: 'T1111',
+ name: 'Multi-Factor Authentication Interception',
+ url: 'https://attack.mitre.org/techniques/T1111/',
+ },
+ {
+ id: 'T1621',
+ name: 'Multi-Factor Authentication Request Generation',
+ url: 'https://attack.mitre.org/techniques/T1621/',
+ },
+ {
+ id: 'T1040',
+ name: 'Network Sniffing',
+ url: 'https://attack.mitre.org/techniques/T1040/',
+ },
+ {
+ id: 'T1003',
+ name: 'OS Credential Dumping',
+ url: 'https://attack.mitre.org/techniques/T1003/',
+ },
+ {
+ id: 'T1528',
+ name: 'Steal Application Access Token',
+ url: 'https://attack.mitre.org/techniques/T1528/',
+ },
+ {
+ id: 'T1649',
+ name: 'Steal or Forge Authentication Certificates',
+ url: 'https://attack.mitre.org/techniques/T1649/',
+ },
+ {
+ id: 'T1558',
+ name: 'Steal or Forge Kerberos Tickets',
+ url: 'https://attack.mitre.org/techniques/T1558/',
+ },
+ {
+ id: 'T1539',
+ name: 'Steal Web Session Cookie',
+ url: 'https://attack.mitre.org/techniques/T1539/',
+ },
+ {
+ id: 'T1552',
+ name: 'Unsecured Credentials',
+ url: 'https://attack.mitre.org/techniques/T1552/',
+ },
+ ];
+
+ return (
+
+
+ Expanded
+
+
+ {({ visibleItems }) => (
+
+ {visibleItems.map((item) => (
+ -
+
+ {item.name}
+
+ {item.id}
+
+ ))}
+
+ )}
+
+
+ );
+ },
+};
+
+export const FlatList: Story = {
+ render: function Render() {
+ const items = [
+ 'Australia',
+ 'Austria',
+ 'Argentina',
+ 'Belgium',
+ 'Brazil',
+ 'United Kingdom',
+ 'Germany',
+ 'Greece',
+ 'Denmark',
+ 'Egypt',
+ 'India',
+ 'Spain',
+ 'Italy',
+ 'Canada',
+ 'Mexico',
+ 'Netherlands',
+ 'Norway',
+ 'Poland',
+ 'Portugal',
+ 'Russia',
+ 'United States',
+ 'Thailand',
+ 'Turkey',
+ 'France',
+ 'Japan',
+ ].map((name, index) => ({ id: index, name, url: '#' }));
+
+ return (
+
+ {({ visibleItems }) => (
+
+ {visibleItems.map((item) => (
+ -
+
+ {item.name}
+ {',\u00a0'}
+
+
+ ))}
+
+ )}
+
+ );
+ },
+};
+
+export const FlatListWithDotSeparators: Story = {
+ render: function Render() {
+ const items = [
+ 'Australia',
+ 'Austria',
+ 'Argentina',
+ 'Belgium',
+ 'Brazil',
+ 'United Kingdom',
+ 'Germany',
+ 'Greece',
+ 'Denmark',
+ 'Egypt',
+ 'India',
+ 'Spain',
+ 'Italy',
+ 'Canada',
+ 'Mexico',
+ 'Netherlands',
+ 'Norway',
+ 'Poland',
+ 'Portugal',
+ 'Russia',
+ 'United States',
+ 'Thailand',
+ 'Turkey',
+ 'France',
+ 'Japan',
+ ].map((name, index) => ({ id: index, name, url: '#' }));
+
+ return (
+
+ {({ visibleItems }) => (
+
+ {visibleItems.map((item) => (
+ -
+
+ {item.name}
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+ },
+};
diff --git a/packages/components/src/components/ClampedList/ClampedList.test.tsx b/packages/components/src/components/ClampedList/ClampedList.test.tsx
new file mode 100644
index 00000000..eeb4087a
--- /dev/null
+++ b/packages/components/src/components/ClampedList/ClampedList.test.tsx
@@ -0,0 +1,532 @@
+import { createRef, type ReactNode } from 'react';
+
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { List } from '../List';
+import { Provider } from '../Provider';
+
+import {
+ ClampedList,
+ type ClampedListProps,
+ type ClampedListState,
+} from './index';
+
+type Item = {
+ id: number;
+ name: string;
+};
+
+const createItems = (length: number): Item[] =>
+ Array.from({ length }, (_, index) => ({
+ id: index + 1,
+ name: `Item ${index + 1}`,
+ }));
+
+function renderItems({
+ visibleItems,
+ hiddenItemCount,
+ isExpanded,
+}: ClampedListState- ): ReactNode {
+ return (
+
+ {visibleItems.map((item) => (
+ - {item.name}
+ ))}
+
+ );
+}
+
+const getRenderedItems = () =>
+ within(screen.getByTestId('items')).getAllByRole('listitem');
+
+describe('ClampedList', () => {
+ it('renders the default collapsed state and accessible trigger', () => {
+ render({renderItems});
+
+ const content = screen.getByRole('group');
+ const trigger = screen.getByRole('button', { name: 'Show 7 more' });
+
+ expect(getRenderedItems()).toHaveLength(10);
+
+ expect(screen.getByTestId('items')).toHaveAttribute(
+ 'data-hidden-count',
+ '7'
+ );
+
+ expect(screen.getByTestId('items')).toHaveAttribute(
+ 'data-expanded',
+ 'false'
+ );
+
+ expect(content.id).not.toBe('');
+ expect(trigger).toHaveAttribute('type', 'button');
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ expect(trigger).toHaveAttribute('aria-controls', content.id);
+ expect(content).not.toContainElement(trigger);
+
+ expect(trigger.querySelector('svg')?.parentElement).toHaveAttribute(
+ 'aria-hidden',
+ 'true'
+ );
+ });
+
+ it('renders a trigger when the hidden count equals the threshold', () => {
+ render({renderItems});
+
+ expect(
+ screen.getByRole('button', { name: 'Show 6 more' })
+ ).toBeInTheDocument();
+
+ expect(getRenderedItems()).toHaveLength(10);
+ });
+
+ it('renders all items without a trigger below the threshold', () => {
+ render({renderItems});
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ expect(getRenderedItems()).toHaveLength(15);
+
+ expect(screen.getByTestId('items')).toHaveAttribute(
+ 'data-hidden-count',
+ '5'
+ );
+
+ expect(screen.getByTestId('items')).toHaveAttribute(
+ 'data-expanded',
+ 'true'
+ );
+ });
+
+ it('renders an empty collection as expanded without a trigger', () => {
+ render({renderItems});
+
+ expect(screen.getByTestId('items')).toBeEmptyDOMElement();
+
+ expect(screen.getByTestId('items')).toHaveAttribute(
+ 'data-hidden-count',
+ '0'
+ );
+
+ expect(screen.getByTestId('items')).toHaveAttribute(
+ 'data-expanded',
+ 'true'
+ );
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('supports custom counts and non-array iterables', () => {
+ const items = new Set(createItems(8));
+
+ render(
+
+ {renderItems}
+
+ );
+
+ expect(getRenderedItems()).toHaveLength(3);
+
+ expect(
+ screen.getByRole('button', { name: 'Show 5 more' })
+ ).toBeInTheDocument();
+ });
+
+ it('normalizes invalid visibility counts', () => {
+ const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const { rerender } = render(
+
+ {renderItems}
+
+ );
+
+ expect(
+ within(screen.getByTestId('items')).queryAllByRole('listitem')
+ ).toHaveLength(0);
+
+ expect(
+ screen.getByRole('button', { name: 'Show 8 more' })
+ ).toBeInTheDocument();
+
+ expect(consoleWarn).toHaveBeenCalledWith(
+ '[koobiq] ClampedList: the "collapsedVisibleCount" prop must be a non-negative integer. The received value was normalized.'
+ );
+
+ expect(consoleWarn).toHaveBeenCalledWith(
+ '[koobiq] ClampedList: the "hiddenThreshold" prop must be a positive integer. The received value was normalized.'
+ );
+
+ rerender(
+
+ {renderItems}
+
+ );
+
+ expect(getRenderedItems()).toHaveLength(3);
+
+ expect(
+ screen.getByRole('button', { name: 'Show 7 more' })
+ ).toBeInTheDocument();
+
+ rerender(
+
+ {renderItems}
+
+ );
+
+ expect(getRenderedItems()).toHaveLength(10);
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+
+ rerender(
+
+ {renderItems}
+
+ );
+
+ expect(getRenderedItems()).toHaveLength(10);
+
+ expect(
+ screen.getByRole('button', { name: 'Show 7 more' })
+ ).toBeInTheDocument();
+
+ consoleWarn.mockRestore();
+ });
+
+ it('keeps a one-shot iterable materialized while toggling', async () => {
+ function* generateItems() {
+ yield* createItems(17);
+ }
+
+ const user = userEvent.setup();
+
+ render({renderItems});
+
+ await user.click(screen.getByRole('button', { name: 'Show 7 more' }));
+
+ expect(getRenderedItems()).toHaveLength(17);
+
+ expect(
+ screen.getByRole('button', { name: 'Collapse' })
+ ).toBeInTheDocument();
+ });
+
+ it('supports defaultExpanded and uncontrolled keyboard toggling', async () => {
+ const onExpandedChange = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+
+ {renderItems}
+
+ );
+
+ const trigger = screen.getByRole('button', { name: 'Collapse' });
+
+ expect(getRenderedItems()).toHaveLength(17);
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+
+ trigger.focus();
+ await user.keyboard('{Enter}');
+
+ expect(getRenderedItems()).toHaveLength(10);
+
+ expect(screen.getByRole('button', { name: 'Show 7 more' })).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ );
+
+ await user.keyboard(' ');
+
+ expect(getRenderedItems()).toHaveLength(17);
+
+ expect(onExpandedChange.mock.calls.map(([value]) => value)).toEqual([
+ false,
+ true,
+ ]);
+ });
+
+ it('supports controlled expansion', async () => {
+ const onExpandedChange = vi.fn();
+ const user = userEvent.setup();
+ const items = createItems(17);
+
+ const { rerender } = render(
+
+ {renderItems}
+
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Show 7 more' }));
+
+ expect(onExpandedChange).toHaveBeenCalledWith(true);
+ expect(getRenderedItems()).toHaveLength(10);
+
+ expect(screen.getByRole('button', { name: 'Show 7 more' })).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ );
+
+ rerender(
+
+ {renderItems}
+
+ );
+
+ expect(getRenderedItems()).toHaveLength(17);
+
+ expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute(
+ 'aria-expanded',
+ 'true'
+ );
+
+ expect(onExpandedChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves the expansion preference while the trigger is unnecessary', async () => {
+ const user = userEvent.setup();
+ const expandedItems = createItems(17);
+
+ const { rerender } = render(
+ {renderItems}
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Show 7 more' }));
+
+ rerender({renderItems});
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ expect(getRenderedItems()).toHaveLength(12);
+
+ rerender({renderItems});
+
+ expect(getRenderedItems()).toHaveLength(17);
+
+ expect(
+ screen.getByRole('button', { name: 'Collapse' })
+ ).toBeInTheDocument();
+ });
+
+ it('supports custom toggle labels', async () => {
+ const user = userEvent.setup();
+
+ render(
+ Reveal all items}
+ lessText={Hide extra items}
+ >
+ {renderItems}
+
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Reveal all items' }));
+
+ expect(
+ screen.getByRole('button', { name: 'Hide extra items' })
+ ).toBeInTheDocument();
+ });
+
+ it('supports hiding the toggle icon', () => {
+ render(
+
+ {renderItems}
+
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'Show 7 more' }).querySelector('svg')
+ ).not.toBeInTheDocument();
+ });
+
+ it('supports a custom toggle icon', async () => {
+ const user = userEvent.setup();
+
+ const toggleIcon = vi.fn((isExpanded: boolean) => (
+ {isExpanded ? 'Up' : 'Down'}
+ ));
+
+ render(
+
+ {renderItems}
+
+ );
+
+ expect(screen.getByTestId('toggle-icon')).toHaveTextContent('Down');
+
+ expect(screen.getByTestId('toggle-icon').parentElement).toHaveAttribute(
+ 'aria-hidden',
+ 'true'
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Show 7 more' }));
+
+ expect(screen.getByTestId('toggle-icon')).toHaveTextContent('Up');
+ expect(toggleIcon).toHaveBeenLastCalledWith(true);
+ });
+
+ it('supports customizing the content and toggle slots', async () => {
+ const contentRef = createRef();
+ const toggleRef = createRef();
+ const onContentClick = vi.fn();
+ const onTogglePress = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+
+ {renderItems}
+
+ );
+
+ const content = screen.getByRole('group');
+ const toggle = screen.getByRole('button', { name: 'Show custom list' });
+
+ expect(contentRef.current).toBe(content);
+ expect(content).toHaveAttribute('id', 'custom-content');
+ expect(content).toHaveClass('custom-content');
+ expect(content.style.color).toBe('red');
+ expect(content).toHaveAttribute('data-testid', 'custom-content');
+
+ expect(toggleRef.current).toBe(toggle);
+ expect(toggle).toHaveAttribute('type', 'button');
+ expect(toggle).toHaveAttribute('aria-controls', 'custom-content');
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ expect(toggle).toHaveClass('custom-toggle');
+ expect(toggle).toHaveStyle({ inlineSize: '100%' });
+ expect(toggle).toHaveAttribute('data-testid', 'custom-toggle');
+
+ fireEvent.click(content);
+ expect(onContentClick).toHaveBeenCalledTimes(1);
+
+ await user.click(toggle);
+
+ expect(getRenderedItems()).toHaveLength(17);
+ expect(onTogglePress).toHaveBeenCalledTimes(1);
+
+ expect(
+ screen.getByRole('button', { name: 'Collapse custom list' })
+ ).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('protects managed toggle ARIA attributes from slot overrides', () => {
+ type ToggleSlotProps = NonNullable<
+ NonNullable['slotProps']>['toggle']
+ >;
+
+ const unsafeToggleProps = {
+ 'aria-controls': 'wrong-content',
+ 'aria-expanded': true,
+ } as unknown as ToggleSlotProps;
+
+ render(
+
+ {renderItems}
+
+ );
+
+ const content = screen.getByRole('group');
+ const toggle = screen.getByRole('button', { name: 'Show 7 more' });
+
+ expect(toggle).toHaveAttribute('aria-controls', content.id);
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('uses localized trigger content', () => {
+ render(
+
+ {renderItems}
+
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'Показать ещё 7' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the trigger outside a nested Koobiq List', async () => {
+ const user = userEvent.setup();
+ const items = createItems(17);
+
+ render(
+
+ {({ visibleItems }) => (
+
+ {(item) => {item.name}}
+
+ )}
+
+ );
+
+ const content = screen.getByRole('group');
+ const listbox = screen.getByRole('listbox', { name: 'Items' });
+ const trigger = screen.getByRole('button', { name: 'Show 7 more' });
+
+ expect(within(listbox).getAllByRole('option')).toHaveLength(10);
+ expect(content).toContainElement(listbox);
+ expect(content).not.toContainElement(trigger);
+ expect(listbox).not.toContainElement(trigger);
+
+ await user.click(trigger);
+
+ expect(within(listbox).getAllByRole('option')).toHaveLength(17);
+ });
+});
diff --git a/packages/components/src/components/ClampedList/ClampedList.tsx b/packages/components/src/components/ClampedList/ClampedList.tsx
new file mode 100644
index 00000000..44963fff
--- /dev/null
+++ b/packages/components/src/components/ClampedList/ClampedList.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { useMemo, type ReactNode } from 'react';
+
+import { once } from '@koobiq/logger';
+import {
+ mergeProps,
+ useControlledState,
+ useId,
+ useLocalizedStringFormatter,
+} from '@koobiq/react-core';
+import { IconChevronDown16, IconChevronUp16 } from '@koobiq/react-icons';
+
+import s from './ClampedList.module.css';
+import { ClampedListTrigger } from './components';
+import intlMessages from './intl';
+import type { ClampedListProps, ClampedListState } from './types';
+
+const DEFAULT_COLLAPSED_VISIBLE_COUNT = 10;
+const DEFAULT_HIDDEN_THRESHOLD = 6;
+
+/**
+ * ClampedList limits the visible portion of a collection and lets the user
+ * expand or collapse it without prescribing the collection markup.
+ */
+export function ClampedList({
+ items,
+ children,
+ collapsedVisibleCount = DEFAULT_COLLAPSED_VISIBLE_COUNT,
+ hiddenThreshold = DEFAULT_HIDDEN_THRESHOLD,
+ isExpanded: isExpandedProp,
+ defaultExpanded,
+ onExpandedChange,
+ moreText,
+ lessText,
+ slotProps,
+}: ClampedListProps) {
+ const allItems = useMemo(() => Array.from(items), [items]);
+ const generatedContentId = useId();
+ const contentId = slotProps?.content?.id ?? generatedContentId;
+ const strings = useLocalizedStringFormatter(intlMessages);
+
+ const normalizedCollapsedVisibleCount = Number.isFinite(collapsedVisibleCount)
+ ? Math.max(0, Math.trunc(collapsedVisibleCount))
+ : DEFAULT_COLLAPSED_VISIBLE_COUNT;
+
+ const normalizedHiddenThreshold = Number.isFinite(hiddenThreshold)
+ ? Math.max(1, Math.trunc(hiddenThreshold))
+ : DEFAULT_HIDDEN_THRESHOLD;
+
+ if (
+ process.env.NODE_ENV !== 'production' &&
+ (!Number.isInteger(collapsedVisibleCount) || collapsedVisibleCount < 0)
+ ) {
+ once.warn(
+ 'ClampedList: the "collapsedVisibleCount" prop must be a non-negative integer. The received value was normalized.'
+ );
+ }
+
+ if (
+ process.env.NODE_ENV !== 'production' &&
+ (!Number.isInteger(hiddenThreshold) || hiddenThreshold < 1)
+ ) {
+ once.warn(
+ 'ClampedList: the "hiddenThreshold" prop must be a positive integer. The received value was normalized.'
+ );
+ }
+
+ const [preferredExpanded, setPreferredExpanded] = useControlledState(
+ isExpandedProp,
+ defaultExpanded ?? false,
+ onExpandedChange
+ );
+
+ const hiddenItemCount = Math.max(
+ allItems.length - normalizedCollapsedVisibleCount,
+ 0
+ );
+
+ const hasToggle =
+ hiddenItemCount > 0 && hiddenItemCount >= normalizedHiddenThreshold;
+
+ const isExpanded = !hasToggle || preferredExpanded;
+
+ const visibleItems = useMemo(
+ () =>
+ isExpanded
+ ? allItems
+ : allItems.slice(0, normalizedCollapsedVisibleCount),
+ [allItems, isExpanded, normalizedCollapsedVisibleCount]
+ );
+
+ const state = useMemo>(
+ () => ({ visibleItems, hiddenItemCount, isExpanded }),
+ [visibleItems, hiddenItemCount, isExpanded]
+ );
+
+ const onToggle = () => setPreferredExpanded((value) => !value);
+
+ const contentProps = mergeProps(
+ { className: s.content },
+ slotProps?.content,
+ { id: contentId, role: 'group' }
+ );
+
+ const { icon: iconProp, ...toggleSlotProps } = slotProps?.toggle ?? {};
+
+ let toggleIcon: ReactNode;
+
+ if (iconProp === undefined) {
+ toggleIcon = isExpanded ? : ;
+ } else if (typeof iconProp === 'function') {
+ toggleIcon = iconProp(isExpanded);
+ } else {
+ toggleIcon = iconProp;
+ }
+
+ let toggleContent: ReactNode;
+
+ if (isExpanded) {
+ toggleContent = lessText ?? strings.format('collapse');
+ } else {
+ toggleContent =
+ moreText ??
+ strings.format('show more', {
+ count: hiddenItemCount,
+ });
+ }
+
+ const toggleProps = mergeProps(
+ {
+ onPress: onToggle,
+ },
+ toggleSlotProps,
+ {
+ 'aria-controls': contentId,
+ 'aria-expanded': isExpanded,
+ children: toggleContent,
+ icon: toggleIcon,
+ }
+ );
+
+ return (
+
+
{children(state)}
+ {hasToggle &&
}
+
+ );
+}
diff --git a/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.module.css b/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.module.css
new file mode 100644
index 00000000..8d5531ab
--- /dev/null
+++ b/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.module.css
@@ -0,0 +1,43 @@
+@import url('../../../../styles/mixins.css');
+
+.icon {
+ display: inline-flex;
+ flex-shrink: 0;
+}
+
+.base {
+ gap: var(--kbq-size-xxs);
+ padding: 0;
+ border: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-block-start: var(--kbq-size-xxs);
+ cursor: pointer;
+ background: none;
+ color: var(--kbq-foreground-theme);
+ outline: var(--kbq-size-3xs) solid transparent;
+ text-decoration: underline;
+ text-decoration-color: transparent;
+ transition:
+ color var(--kbq-transition-default),
+ outline var(--kbq-transition-default),
+ text-decoration-color var(--kbq-transition-default);
+
+ @mixin typography text-normal;
+
+ &[data-hovered] {
+ color: var(--kbq-states-foreground-theme-hover);
+ text-decoration-color: var(--kbq-line-theme-less);
+ }
+
+ &[data-pressed] {
+ color: var(--kbq-states-foreground-theme-active);
+ text-decoration-color: var(--kbq-line-theme-less);
+ }
+
+ &[data-focus-visible] {
+ color: var(--kbq-foreground-theme);
+ outline-color: var(--kbq-states-line-focus-theme);
+ }
+}
diff --git a/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.tsx b/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.tsx
new file mode 100644
index 00000000..ccb0f805
--- /dev/null
+++ b/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { forwardRef } from 'react';
+
+import { clsx } from '@koobiq/react-core';
+import {
+ Button as ButtonPrimitive,
+ composeRenderProps,
+} from '@koobiq/react-primitives';
+
+import s from './ClampedListTrigger.module.css';
+import type { ClampedListTriggerProps } from './types';
+
+export const ClampedListTrigger = forwardRef<
+ HTMLButtonElement,
+ ClampedListTriggerProps
+>(({ children, className, icon, ...props }, ref) => (
+ clsx(s.base, value))}
+ >
+ {icon == null || icon === false ? null : (
+
+ {icon}
+
+ )}
+ {children}
+
+));
+
+ClampedListTrigger.displayName = 'ClampedListTrigger';
diff --git a/packages/components/src/components/ClampedList/components/ClampedListTrigger/index.ts b/packages/components/src/components/ClampedList/components/ClampedListTrigger/index.ts
new file mode 100644
index 00000000..157a1659
--- /dev/null
+++ b/packages/components/src/components/ClampedList/components/ClampedListTrigger/index.ts
@@ -0,0 +1,2 @@
+export * from './ClampedListTrigger';
+export type * from './types';
diff --git a/packages/components/src/components/ClampedList/components/ClampedListTrigger/types.ts b/packages/components/src/components/ClampedList/components/ClampedListTrigger/types.ts
new file mode 100644
index 00000000..d29db471
--- /dev/null
+++ b/packages/components/src/components/ClampedList/components/ClampedListTrigger/types.ts
@@ -0,0 +1,14 @@
+import type { ReactNode } from 'react';
+
+import type { DataAttributeProps } from '@koobiq/react-core';
+import type { ButtonProps } from '@koobiq/react-primitives';
+
+export type ClampedListTriggerProps = Omit<
+ ButtonProps,
+ 'as' | 'children' | 'type'
+> &
+ DataAttributeProps & {
+ children: ReactNode;
+ /** Icon displayed before the toggle content. */
+ icon?: ReactNode;
+ };
diff --git a/packages/components/src/components/ClampedList/components/index.ts b/packages/components/src/components/ClampedList/components/index.ts
new file mode 100644
index 00000000..a280bc2e
--- /dev/null
+++ b/packages/components/src/components/ClampedList/components/index.ts
@@ -0,0 +1 @@
+export * from './ClampedListTrigger';
diff --git a/packages/components/src/components/ClampedList/index.ts b/packages/components/src/components/ClampedList/index.ts
new file mode 100644
index 00000000..011a6fb4
--- /dev/null
+++ b/packages/components/src/components/ClampedList/index.ts
@@ -0,0 +1,3 @@
+export * from './ClampedList';
+export * from './types';
+export type { ClampedListTriggerProps } from './components';
diff --git a/packages/components/src/components/ClampedList/intl.ts b/packages/components/src/components/ClampedList/intl.ts
new file mode 100644
index 00000000..e6400488
--- /dev/null
+++ b/packages/components/src/components/ClampedList/intl.ts
@@ -0,0 +1,10 @@
+export default {
+ 'ru-RU': {
+ collapse: 'Свернуть',
+ 'show more': ({ count }: { count: number }) => `Показать ещё ${count}`,
+ },
+ 'en-US': {
+ collapse: 'Collapse',
+ 'show more': ({ count }: { count: number }) => `Show ${count} more`,
+ },
+} as unknown as Record>;
diff --git a/packages/components/src/components/ClampedList/types.ts b/packages/components/src/components/ClampedList/types.ts
new file mode 100644
index 00000000..9de455cb
--- /dev/null
+++ b/packages/components/src/components/ClampedList/types.ts
@@ -0,0 +1,59 @@
+import type { ComponentPropsWithRef, ReactNode } from 'react';
+
+import type { DataAttributeProps } from '@koobiq/react-core';
+
+import type { ClampedListTriggerProps } from './components';
+
+export type ClampedListState = {
+ /** Items currently exposed to the render function. */
+ visibleItems: T[];
+ /** Number of items that would be hidden in the collapsed state. */
+ hiddenItemCount: number;
+ /** Whether all items are currently visible. */
+ isExpanded: boolean;
+};
+
+export type ClampedListProps = {
+ /** Collection of items managed by the component. */
+ items: Iterable;
+ /** Renders the visible portion of the collection. */
+ children: (state: ClampedListState) => ReactNode;
+ /**
+ * Maximum number of visible items in the collapsed state.
+ * @default 10
+ */
+ collapsedVisibleCount?: number;
+ /**
+ * Minimum number of hidden items required to render the toggle.
+ * @default 6
+ */
+ hiddenThreshold?: number;
+ /** Whether all items are visible. */
+ isExpanded?: boolean;
+ /**
+ * Whether all items are visible by default.
+ * @default false
+ */
+ defaultExpanded?: boolean;
+ /** Handler called when the user toggles the expanded state. */
+ onExpandedChange?: (isExpanded: boolean) => void;
+ /** Content displayed in the toggle when the list is collapsed. */
+ moreText?: ReactNode;
+ /** Content displayed in the toggle when the list is expanded. */
+ lessText?: ReactNode;
+ /** The props used for each slot inside. */
+ slotProps?: {
+ content?: Omit, 'children'> &
+ DataAttributeProps;
+ toggle?: Omit<
+ ClampedListTriggerProps,
+ 'children' | 'icon' | 'aria-controls' | 'aria-expanded'
+ > & {
+ /**
+ * Icon displayed before the toggle content. Pass `null` to hide it or a
+ * render function to use the current expanded state.
+ */
+ icon?: ReactNode | ((isExpanded: boolean) => ReactNode);
+ };
+ };
+};
diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts
index 957979b1..8af9f784 100644
--- a/packages/components/src/components/index.ts
+++ b/packages/components/src/components/index.ts
@@ -12,6 +12,7 @@ export * from './Typography';
export * from './Checkbox';
export * from './CheckboxGroup';
export * from './ClampedText';
+export * from './ClampedList';
export * from './Link';
export * from './Badge';
export * from './Input';
diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json
index 5087d68c..9fb1d368 100644
--- a/tools/api-extractor/config.json
+++ b/tools/api-extractor/config.json
@@ -14,6 +14,7 @@
"Checkbox",
"CheckboxGroup",
"ClampedText",
+ "ClampedList",
"Container",
"ContentPanel",
"DateInput",
diff --git a/tools/public_api_guard/components/ClampedList.api.md b/tools/public_api_guard/components/ClampedList.api.md
new file mode 100644
index 00000000..1e2e9a6f
--- /dev/null
+++ b/tools/public_api_guard/components/ClampedList.api.md
@@ -0,0 +1,50 @@
+## API Report File for "koobiq-react"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import type { ButtonProps } from '@koobiq/react-primitives';
+import type { ComponentPropsWithRef } from 'react';
+import type { DataAttributeProps } from '@koobiq/react-core';
+import { JSX } from 'react/jsx-runtime';
+import type { ReactNode } from 'react';
+
+// @public
+export function ClampedList(input: ClampedListProps): JSX.Element;
+
+// @public (undocumented)
+export type ClampedListProps = {
+ items: Iterable;
+ children: (state: ClampedListState) => ReactNode;
+ collapsedVisibleCount?: number;
+ hiddenThreshold?: number;
+ isExpanded?: boolean;
+ defaultExpanded?: boolean;
+ onExpandedChange?: (isExpanded: boolean) => void;
+ moreText?: ReactNode;
+ lessText?: ReactNode;
+ slotProps?: {
+ content?: Omit, 'children'> & DataAttributeProps;
+ toggle?: Omit & {
+ icon?: ReactNode | ((isExpanded: boolean) => ReactNode);
+ };
+ };
+};
+
+// @public (undocumented)
+export type ClampedListState = {
+ visibleItems: T[];
+ hiddenItemCount: number;
+ isExpanded: boolean;
+};
+
+// @public (undocumented)
+export type ClampedListTriggerProps = Omit & DataAttributeProps & {
+ children: ReactNode;
+ icon?: ReactNode;
+};
+
+// (No @packageDocumentation comment for this package)
+
+```