diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts
index e6dd1cd2..9027c279 100644
--- a/.storybook/components/Roadmap/data.ts
+++ b/.storybook/components/Roadmap/data.ts
@@ -427,7 +427,8 @@ export const rows: Rows = [
},
{
component: 'ClampedText',
- status: '🚧 Planned',
+ status: '✅ Done',
+ stage: '🔵 experimental',
planned: 'Q3 2026',
},
{
diff --git a/packages/components/src/components/ClampedText/ClampedText.mdx b/packages/components/src/components/ClampedText/ClampedText.mdx
new file mode 100644
index 00000000..b3367d61
--- /dev/null
+++ b/packages/components/src/components/ClampedText/ClampedText.mdx
@@ -0,0 +1,77 @@
+import {
+ Meta,
+ Story,
+ Props,
+ Status,
+} from '../../../../../.storybook/components';
+
+import * as Stories from './ClampedText.stories';
+
+
+
+# ClampedText
+
+
+
+`ClampedText` keeps long text compact while allowing users to reveal the full content.
+
+## Import
+
+```tsx
+import { ClampedText } from '@koobiq/react-components';
+```
+
+## Usage
+
+
+
+## Props
+
+
+
+## Number of rows
+
+The collapsed component displays `5` rows by default. The toggle appears only
+when the content exceeds `rows + 1`, so a single additional row is shown in
+full.
+
+Use the `rows` prop to configure the number of rows visible while collapsed.
+
+
+
+## Structured content
+
+ClampedText supports text split across semantic block elements,
+including headings and paragraphs rendered with Typography.
+The row limit applies to their shared content.
+
+
+
+## Controlled expansion
+
+Use the `isExpanded` prop with the `onExpandedChange` prop when expansion state is owned by the application.
+For an uncontrolled initial state, use the `defaultExpanded` prop.
+
+
+
+## Resizing
+
+The `ClampedText` recalculates the number of rows when its container changes size.
+The last expansion preference is preserved while the toggle is temporarily unnecessary.
+
+
+
+## Customization
+
+Use the `moreText` and `lessText` props to replace the localized toggle labels.
+
+Use the `slotProps.content` and `slotProps.toggle` props to customize the content container
+and toggle.
+
+
+
+## Accessibility
+
+The toggle is a native button with `aria-expanded` and `aria-controls`. It supports mouse, touch, Enter, and Space interactions.
+
+Interactive elements inside collapsed content remain in keyboard navigation even when they are visually clipped. When a hidden link receives focus, the browser may reveal part of the clipped area and cause an unpleasant visual effect. Account for this behavior when placing links or controls inside ClampedText.
diff --git a/packages/components/src/components/ClampedText/ClampedText.module.css b/packages/components/src/components/ClampedText/ClampedText.module.css
new file mode 100644
index 00000000..b39345d0
--- /dev/null
+++ b/packages/components/src/components/ClampedText/ClampedText.module.css
@@ -0,0 +1,62 @@
+@import url('../../styles/mixins.css');
+
+.base {
+ display: inline-flex;
+ max-inline-size: 100%;
+ flex-direction: column;
+
+ @mixin typography text-normal;
+}
+
+.content {
+ min-inline-size: 0;
+}
+
+.clamped {
+ display: -webkit-box;
+ overflow: hidden;
+
+ /* Required by the legacy -webkit-line-clamp implementation. */
+ /* stylelint-disable-next-line plugin/use-logical-properties-and-values */
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: var(--clamped-text-rows);
+ line-clamp: var(--clamped-text-rows);
+}
+
+.toggle {
+ align-self: flex-start;
+ margin-block-start: var(--kbq-size-xxs);
+ padding: 0;
+ border: none;
+ gap: var(--kbq-size-xxs);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ 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/ClampedText/ClampedText.stories.tsx b/packages/components/src/components/ClampedText/ClampedText.stories.tsx
new file mode 100644
index 00000000..490adb93
--- /dev/null
+++ b/packages/components/src/components/ClampedText/ClampedText.stories.tsx
@@ -0,0 +1,127 @@
+import { useState } from 'react';
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { FlexBox } from '../FlexBox';
+import { spacing } from '../layout';
+import { Toggle } from '../Toggle';
+import { Typography } from '../Typography';
+
+import { ClampedText, type ClampedTextProps } from './index';
+
+const meta = {
+ title: 'Components/ClampedText',
+ component: ClampedText,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['status:new', 'date:2026-08-12'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const text =
+ 'In a distributed denial-of-service attack (DDoS attack), the incoming traffic flooding the victim originates from many different sources. More sophisticated strategies are required to mitigate this type of attack; simply attempting to block a single source is insufficient as there are multiple sources. A DoS or DDoS attack is analogous to a group of people crowding the entry door of a shop, making it hard for legitimate customers to enter, thus disrupting trade and losing the business money. Criminal perpetrators of DoS attacks often target sites or services hosted on high-profile web servers such as banks or credit card payment gateways. Revenge and blackmail, as well as hacktivism, can motivate these attacks.';
+
+export const Base: Story = {
+ render: (args) => {
+ return {text};
+ },
+};
+
+export const Rows: Story = {
+ render: (args) => {
+ return (
+
+ {text}
+
+ );
+ },
+};
+
+export const StructuredContent: Story = {
+ render: (args) => (
+
+
+ Line clamp with structured content
+
+ {text}
+
+ ),
+};
+
+export const ControlledExpansion: Story = {
+ render: function Render(args) {
+ const [isExpanded, setExpanded] = useState(false);
+
+ return (
+
+ {text}
+
+ );
+ },
+};
+
+export const ResizePersistence: Story = {
+ parameters: {
+ layout: 'padded',
+ },
+ render: function Render(args) {
+ const [isNarrow, setNarrow] = useState(false);
+
+ const text =
+ 'The expansion preference is preserved when resizing temporarily makes all of the text visible. Narrow the container again and the component restores the state selected before the resize.';
+
+ return (
+
+
+ Narrow
+
+
+ {text}
+
+
+ );
+ },
+};
+
+export const Customization: Story = {
+ render: (args) => {
+ return (
+
+ {text}
+
+ );
+ },
+};
diff --git a/packages/components/src/components/ClampedText/ClampedText.test.tsx b/packages/components/src/components/ClampedText/ClampedText.test.tsx
new file mode 100644
index 00000000..1cfdbf6d
--- /dev/null
+++ b/packages/components/src/components/ClampedText/ClampedText.test.tsx
@@ -0,0 +1,618 @@
+import { createRef, type SVGProps } from 'react';
+
+import { once } from '@koobiq/logger';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { hydrateRoot } from 'react-dom/client';
+import { renderToString } from 'react-dom/server';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { Provider } from '../Provider';
+import { Typography } from '../Typography';
+
+import { ClampedText } from './ClampedText';
+
+vi.mock('@koobiq/react-icons', () => ({
+ IconChevronDown16: (props: SVGProps) => (
+
+ ),
+ IconChevronUp16: (props: SVGProps) => (
+
+ ),
+}));
+
+const createResizeEntry = (width: number): ResizeObserverEntry =>
+ ({
+ contentRect: {
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: width,
+ bottom: 100,
+ width,
+ height: 100,
+ },
+ borderBoxSize: [{ inlineSize: width, blockSize: 100 }],
+ }) as unknown as ResizeObserverEntry;
+
+const createClientRects = (tops: number[]): DOMRectList => {
+ const rects = tops.map(
+ (top) =>
+ ({
+ x: 0,
+ y: top,
+ top,
+ left: 0,
+ right: 100,
+ bottom: top + 20,
+ width: 100,
+ height: 20,
+ toJSON: () => ({}),
+ }) as DOMRect
+ );
+
+ return Object.assign(rects, {
+ item: (index: number) => rects[index] ?? null,
+ }) as DOMRectList;
+};
+
+describe('ClampedText', () => {
+ let resize: ResizeObserverCallback;
+ let rowTops: number[];
+ const observe = vi.fn();
+ const disconnect = vi.fn();
+ const scrollIntoView = vi.fn();
+ const selectNodeContents = vi.fn();
+ let scrollIntoViewDescriptor: PropertyDescriptor | undefined;
+
+ beforeEach(() => {
+ rowTops = [];
+
+ scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'scrollIntoView'
+ );
+
+ class ResizeObserverMock {
+ constructor(callback: ResizeObserverCallback) {
+ resize = callback;
+ }
+
+ observe = observe;
+ disconnect = disconnect;
+ }
+
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock);
+
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+ callback(0);
+
+ return 1;
+ });
+
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
+
+ vi.spyOn(document, 'createRange').mockReturnValue({
+ selectNodeContents,
+ getClientRects: () => createClientRects(rowTops),
+ } as unknown as Range);
+
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
+ configurable: true,
+ value: scrollIntoView,
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ vi.clearAllMocks();
+ once.clear();
+
+ if (scrollIntoViewDescriptor) {
+ Object.defineProperty(
+ HTMLElement.prototype,
+ 'scrollIntoView',
+ scrollIntoViewDescriptor
+ );
+ } else {
+ Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView');
+ }
+ });
+
+ it('forwards the ref and root element props', () => {
+ const ref = createRef();
+
+ render(
+
+ Text
+
+ );
+
+ expect(ref.current).toBe(screen.getByTestId('root'));
+ expect(ref.current).toHaveClass('custom');
+ expect(ref.current).toHaveStyle({ padding: '4px' });
+ });
+
+ it('supports custom labels and content and toggle slot props', async () => {
+ rowTops = [0, 20, 40, 60];
+ const contentRef = createRef();
+ const toggleRef = createRef();
+ const onContentClick = vi.fn();
+ const onTogglePress = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+ Show full text}
+ lessText={Show less text}
+ data-testid="root"
+ slotProps={{
+ content: {
+ id: 'custom-content',
+ ref: contentRef,
+ className: 'custom-content',
+ style: { color: 'red' },
+ onClick: onContentClick,
+ },
+ toggle: {
+ ref: toggleRef,
+ className: 'custom-toggle',
+ style: { color: 'blue' },
+ 'data-testid': 'toggle',
+ onPress: onTogglePress,
+ },
+ }}
+ >
+ Long text
+
+ );
+
+ const root = screen.getByTestId('root');
+ const content = contentRef.current!;
+ const toggle = screen.getByTestId('toggle');
+
+ expect(root).toHaveAttribute('id', 'custom-root');
+ expect(content).toHaveAttribute('id', 'custom-content');
+ expect(content).toHaveClass('custom-content');
+ expect(content.style.color).toBe('red');
+ expect(content.style.getPropertyValue('--clamped-text-rows')).toBe('2');
+
+ expect(toggleRef.current).toBe(toggle);
+ expect(toggle).toHaveClass('custom-toggle');
+ expect(toggle.style.color).toBe('blue');
+ expect(toggle).toHaveAttribute('aria-controls', 'custom-content');
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ expect(toggle).toHaveAccessibleName('Show full text');
+
+ fireEvent.click(content);
+ expect(onContentClick).toHaveBeenCalledTimes(1);
+
+ await user.click(toggle);
+
+ expect(onTogglePress).toHaveBeenCalledTimes(1);
+ expect(toggle).toHaveAccessibleName('Show less text');
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('renders on the server without browser measurement APIs', () => {
+ const documentDescriptor = Object.getOwnPropertyDescriptor(
+ globalThis,
+ 'document'
+ );
+
+ Object.defineProperty(globalThis, 'document', {
+ configurable: true,
+ value: undefined,
+ });
+
+ vi.stubGlobal('Range', undefined);
+ vi.stubGlobal('ResizeObserver', undefined);
+
+ try {
+ const html = renderToString(
+
+ Server text
+
+ );
+
+ expect(html).toContain('id="server-root"');
+ expect(html).toContain('id="server-content"');
+ expect(html).toContain('Server text');
+ expect(html).toContain('data-clamped="true"');
+ expect(html).toContain('--clamped-text-rows:6');
+ expect(html).not.toContain('