{"_id":"@apollosuny/react-truncate","_rev":"4-a81705b703536a4976f0007c3b78b40a","name":"@apollosuny/react-truncate","dist-tags":{"latest":"0.3.1"},"versions":{"0.1.0":{"name":"@apollosuny/react-truncate","version":"0.1.0","keywords":["react","truncate","text","ellipsis","show more","see more","clamp"],"author":{"name":"apollosuny"},"license":"MIT","_id":"@apollosuny/react-truncate@0.1.0","maintainers":[{"name":"apollosuny","email":"baotrung06092003@gmail.com"}],"homepage":"https://github.com/apollosuny/react-truncate#readme","bugs":{"url":"https://github.com/apollosuny/react-truncate/issues"},"dist":{"shasum":"252c1f5b3ff84d0c28d888c98e2a5808463636ec","tarball":"https://registry.npmjs.org/@apollosuny/react-truncate/-/react-truncate-0.1.0.tgz","fileCount":9,"integrity":"sha512-F/cJCp7HbA8tx29U2ySeELq1ojqgJUTn4Ul34sdAE0iyHy2fiPVaS3murE1z8pAdpkaVlBG4+uMXu5XywmuTzw==","signatures":[{"sig":"MEQCIHl9FKtEkyrcNcL7a9+15vvTBkiy3yOr2teY95nBmdiKAiA+AnUZNK0N75QZKXJXT0/d5dHGh74VvHlsFwgTf//kOA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":74659},"main":"./dist/index.js","types":"./dist/index.d.ts","module":"./dist/index.mjs","exports":{".":{"import":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"require":{"types":"./dist/index.d.ts","default":"./dist/index.js"}}},"gitHead":"4d8270e3f633f96f8030c2c0d2e2b9fe27c7714d","scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","storybook":"storybook dev -p 6006","typecheck":"tsc --noEmit","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"pnpm build","build:storybook":"storybook build"},"_npmUser":{"name":"apollosuny","email":"baotrung06092003@gmail.com"},"repository":{"url":"git+https://github.com/apollosuny/react-truncate.git","type":"git"},"_npmVersion":"10.9.2","description":"A modern, pixel-accurate React text truncation component with show more/less toggle","directories":{},"sideEffects":false,"_nodeVersion":"23.9.0","dependencies":{"@radix-ui/react-slot":"^1.2.3"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.0","jsdom":"^26.1.0","react":"^19.1.0","vitest":"^3.2.2","storybook":"^8.6.18","typescript":"^5.8.3","@types/react":"^19.1.4","@storybook/react":"^8.6.18","@storybook/blocks":"^8.6.14","@vitest/coverage-v8":"^3.2.4","@vitejs/plugin-react":"^6.0.1","@storybook/react-vite":"^8.6.18","@testing-library/react":"^16.3.0","@testing-library/jest-dom":"^6.6.3","@storybook/addon-essentials":"^8.6.18","@testing-library/user-event":"^14.5.2"},"peerDependencies":{"react":"^18.0.0 || ^19.0.0 || ^19.0.0-rc"},"peerDependenciesMeta":{"@types/react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/react-truncate_0.1.0_1778686661430_0.5790129080160415","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@apollosuny/react-truncate","version":"0.2.0","keywords":["react","truncate","text","ellipsis","show more","see more","clamp"],"author":{"name":"apollosuny"},"license":"MIT","_id":"@apollosuny/react-truncate@0.2.0","maintainers":[{"name":"apollosuny","email":"baotrung06092003@gmail.com"}],"homepage":"https://github.com/apollosuny/react-truncate#readme","bugs":{"url":"https://github.com/apollosuny/react-truncate/issues"},"dist":{"shasum":"00dba5e862c301d538b43c4e4b6919d6c2fcf1f1","tarball":"https://registry.npmjs.org/@apollosuny/react-truncate/-/react-truncate-0.2.0.tgz","fileCount":9,"integrity":"sha512-Rh/Ivq5DoghO4vZXoyKLDlvw0AkoUwf+FcRdHQTLo8ouEkYhXnGNjII+FY8D0pcfi4NGhuSLu3rB60tvAVjquA==","signatures":[{"sig":"MEQCICIWg6h4IKDrwGzRZevAwi3cW+boleBhiI59Yts+WMM0AiAaRbmUT5i7MLpu7L+HXlZaqIsn0CFWqdr2YGEwZ1vAPg==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":87768},"main":"./dist/index.js","types":"./dist/index.d.ts","module":"./dist/index.mjs","exports":{".":{"import":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"require":{"types":"./dist/index.d.ts","default":"./dist/index.js"}}},"gitHead":"1c64fa70419a1f849e6b048786434961cd6f9434","scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","storybook":"storybook dev -p 6006","typecheck":"tsc --noEmit","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"pnpm build","build:storybook":"storybook build"},"_npmUser":{"name":"apollosuny","email":"baotrung06092003@gmail.com"},"repository":{"url":"git+https://github.com/apollosuny/react-truncate.git","type":"git"},"_npmVersion":"10.9.2","description":"A modern, pixel-accurate React text truncation component with show more/less toggle","directories":{},"sideEffects":false,"_nodeVersion":"23.9.0","dependencies":{"@radix-ui/react-slot":"^1.2.3"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.0","jsdom":"^26.1.0","react":"^19.1.0","vitest":"^3.2.2","storybook":"^8.6.18","typescript":"^5.8.3","@types/react":"^19.1.4","@storybook/react":"^8.6.18","@storybook/blocks":"^8.6.14","@vitest/coverage-v8":"^3.2.4","@vitejs/plugin-react":"^6.0.1","@storybook/react-vite":"^8.6.18","@testing-library/react":"^16.3.0","@testing-library/jest-dom":"^6.6.3","@storybook/addon-essentials":"^8.6.18","@testing-library/user-event":"^14.5.2"},"peerDependencies":{"react":"^18.0.0 || ^19.0.0 || ^19.0.0-rc"},"peerDependenciesMeta":{"@types/react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/react-truncate_0.2.0_1780754135978_0.39888615312481646","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@apollosuny/react-truncate","version":"0.3.0","keywords":["react","truncate","text","ellipsis","show more","see more","clamp"],"author":{"name":"apollosuny"},"license":"MIT","_id":"@apollosuny/react-truncate@0.3.0","maintainers":[{"name":"apollosuny","email":"baotrung06092003@gmail.com"}],"homepage":"https://github.com/apollosuny/react-truncate#readme","bugs":{"url":"https://github.com/apollosuny/react-truncate/issues"},"dist":{"shasum":"92ac65be4fc99ad3a8cd889feeea6bce20563644","tarball":"https://registry.npmjs.org/@apollosuny/react-truncate/-/react-truncate-0.3.0.tgz","fileCount":9,"integrity":"sha512-RG5b2kN2NUsGcUDHvx7EjFoQUerv+CacHf6iMkJCjvXLF6VqZdf6jX3FwKrZkd4Bm4y4aU2I8OziHYZ/NGG2dQ==","signatures":[{"sig":"MEUCIQDKmOcRZ13N+MJtiH2ZyZ7R04CWNA+yHbFr6RnlMjT0MQIgf9uPBakVMrfpB5UBko4ini0s4DAREuEIA9WqZeQxV1s=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":121515},"main":"./dist/index.js","types":"./dist/index.d.ts","module":"./dist/index.mjs","exports":{".":{"import":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"require":{"types":"./dist/index.d.ts","default":"./dist/index.js"}}},"gitHead":"382fade57046af21e7608ee8dfa7285577636f93","scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","storybook":"storybook dev -p 6006","typecheck":"tsc --noEmit","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"pnpm build","build:storybook":"storybook build"},"_npmUser":{"name":"apollosuny","email":"baotrung06092003@gmail.com"},"repository":{"url":"git+https://github.com/apollosuny/react-truncate.git","type":"git"},"_npmVersion":"10.9.2","description":"A modern, pixel-accurate React text truncation component with show more/less toggle","directories":{},"sideEffects":false,"_nodeVersion":"23.9.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.0","jsdom":"^26.1.0","react":"^19.1.0","vitest":"^3.2.2","storybook":"^8.6.18","typescript":"^5.8.3","@types/node":"^25.9.2","@types/react":"^19.1.4","@storybook/react":"^8.6.18","@storybook/blocks":"^8.6.14","@vitest/coverage-v8":"^3.2.4","@vitejs/plugin-react":"^6.0.1","@storybook/react-vite":"^8.6.18","@testing-library/react":"^16.3.0","@testing-library/jest-dom":"^6.6.3","@storybook/addon-essentials":"^8.6.18","@testing-library/user-event":"^14.5.2"},"peerDependencies":{"react":"^18.0.0 || ^19.0.0 || ^19.0.0-rc"},"peerDependenciesMeta":{"@types/react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/react-truncate_0.3.0_1783401943506_0.08257254400495229","host":"s3://npm-registry-packages-npm-production"}},"0.3.1":{"_id":"@apollosuny/react-truncate@0.3.1","bugs":{"url":"https://github.com/apollosuny/react-truncate/issues"},"dist":{"shasum":"ca1287281392be6aba17da5331a76600cec1c068","tarball":"https://registry.npmjs.org/@apollosuny/react-truncate/-/react-truncate-0.3.1.tgz","fileCount":9,"integrity":"sha512-WAVTxJSTB7bpbOCfjPbjEUE0sx/XLWkxKkIfegrfOOke4684853fNz8ZsSkxTXwSvZyhyDIK5HgxSIuRkG09WQ==","signatures":[{"sig":"MEUCIQDK/5YcS6hHhARjOxqt2k2APAj2nHIgEbBxW+V8LioOyQIgTeJAwEFBFJiMzMk4Cf7K2+NhTJSp/Uobj9eBz+iGN/I=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDg3yvbT22ozPcDUjxCTLiIT05MhR8xBaH/glv8MmCCQgIgNsL72hRYFXpldHvUWZ9q4KMrevll3idwwITYfiSz9gQ="}],"unpackedSize":121744},"main":"./dist/index.js","name":"@apollosuny/react-truncate","types":"./dist/index.d.ts","author":{"name":"apollosuny"},"module":"./dist/index.mjs","exports":{".":{"import":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"require":{"types":"./dist/index.d.ts","default":"./dist/index.js"}}},"gitHead":"dcf9ffd742e8548568329b920e7c3f438b4ec3eb","license":"MIT","scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","storybook":"storybook dev -p 6006","typecheck":"tsc --noEmit","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"pnpm build","build:storybook":"storybook build"},"version":"0.3.1","_npmUser":{"name":"apollosuny","email":"baotrung06092003@gmail.com"},"homepage":"https://react-truncate-alpha.vercel.app/","keywords":["react","truncate","text","ellipsis","show more","show-more-text","see more","read more","clamp","line-clamp","emoji","grapheme","rtl","accessible","app-router","server-component","zero-dependency"],"repository":{"url":"git+https://github.com/apollosuny/react-truncate.git","type":"git"},"_npmVersion":"10.9.2","description":"A modern, pixel-accurate React text truncation component with show more/less toggle","directories":{},"maintainers":[{"name":"apollosuny","email":"baotrung06092003@gmail.com"}],"sideEffects":false,"_nodeVersion":"23.9.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","jsdom":"^29.1.1","react":"^19.3.0","vitest":"^3.2.7","storybook":"^8.6.18","typescript":"^5.9.3","@types/node":"^26.6.3","@types/react":"^19.3.0","@storybook/react":"^8.6.18","@storybook/blocks":"^8.6.14","@vitest/coverage-v8":"^3.2.7","@vitejs/plugin-react":"^6.1.1","@storybook/react-vite":"^8.6.18","@testing-library/react":"^16.3.3","@testing-library/jest-dom":"^6.9.1","@storybook/addon-essentials":"^8.6.18","@testing-library/user-event":"^14.6.7"},"peerDependencies":{"react":"^18.0.0 || ^19.0.0 || ^19.0.0-rc"},"peerDependenciesMeta":{"@types/react":{"optional":true}},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/react-truncate_0.3.1_1790699846069_0.3840327733673303"}}},"time":{"created":"2026-05-13T15:37:41.323Z","modified":"2026-09-29T16:37:26.309Z","0.1.0":"2026-05-13T15:37:41.583Z","0.2.0":"2026-06-06T13:55:36.103Z","0.3.0":"2026-07-07T05:25:43.614Z","0.3.1":"2026-09-29T16:37:26.147Z"},"bugs":{"url":"https://github.com/apollosuny/react-truncate/issues"},"author":{"name":"apollosuny"},"license":"MIT","homepage":"https://react-truncate-alpha.vercel.app/","keywords":["react","truncate","text","ellipsis","show more","show-more-text","see more","read more","clamp","line-clamp","emoji","grapheme","rtl","accessible","app-router","server-component","zero-dependency"],"repository":{"url":"git+https://github.com/apollosuny/react-truncate.git","type":"git"},"description":"A modern, pixel-accurate React text truncation component with show more/less toggle","maintainers":[{"name":"apollosuny","email":"baotrung06092003@gmail.com"}],"readme":"# @apollosuny/react-truncate\n\n[![npm version](https://img.shields.io/npm/v/@apollosuny/react-truncate)](https://www.npmjs.com/package/@apollosuny/react-truncate)\n[![bundle size](https://img.shields.io/bundlephobia/minzip/@apollosuny/react-truncate)](https://bundlephobia.com/package/@apollosuny/react-truncate)\n[![license](https://img.shields.io/npm/l/@apollosuny/react-truncate)](./LICENSE)\n[![react](https://img.shields.io/badge/react-18%20%7C%2019-blue)](https://react.dev)\n\nA pixel-accurate, responsive React text truncation component with inline \"see more / see less\" toggle.\n\nUnlike CSS `-webkit-line-clamp`, this library uses `canvas.measureText()` and binary search to find the exact character cutoff at any container width, font, or letter-spacing — then re-runs automatically on resize.\n\n**[🔗 Live demo & interactive playground →](https://react-truncate-alpha.vercel.app/)**\n\n---\n\n## Features\n\n- **Pixel-accurate** — `canvas.measureText()` + binary search, not CSS hacks\n- **Responsive** — `ResizeObserver` recalculates on every container resize\n- **Inline toggle** — place \"see more\" _and_ \"see less\" at the end of the last line, like Facebook\n- **Unstyled** — zero CSS shipped; style with `className`, Tailwind, CSS Modules, anything\n- **Composable** — compound component API (`<Truncate.Content>`, `<Truncate.Toggle>`)\n- **`asChild`** — render the toggle as any element (e.g. `<a>`, a custom button) via a built-in slot\n- **Emoji-safe** — cuts on grapheme clusters (`Intl.Segmenter`), so emoji, flags, and combining marks are never split\n- **Zero dependencies** — no runtime dependencies; only a `react` peer\n- **Controlled + uncontrolled** — `expanded` / `defaultExpanded` / `onExpandedChange`\n- **Accessible** — `aria-expanded` / `aria-controls` wired automatically, and the **full text stays readable by screen readers** while visually clipped\n- **Font-aware** — re-measures after web fonts load, so the cutoff doesn't drift\n- **RSC-ready** — ships a `\"use client\"` directive, so it drops straight into a Next.js App Router server component with no wrapper, and still SSRs to HTML\n- **TypeScript** — full type definitions included\n\n---\n\n## Installation\n\n```bash\nnpm install @apollosuny/react-truncate\n# or\npnpm add @apollosuny/react-truncate\n# or\nyarn add @apollosuny/react-truncate\n```\n\n**Peer dependencies:** `react@^18 || ^19`\n\n---\n\n## Quick Start\n\nThe simplest form — a string child plus `moreLabel` / `lessLabel`. The library\nrenders accessible inline buttons for you (wired with `aria-expanded` /\n`aria-controls`):\n\n```tsx\nimport { Truncate } from \"@apollosuny/react-truncate\";\n\nexport function Post({ body }: { body: string }) {\n  return (\n    <Truncate lines={3} moreLabel=\"See more\" lessLabel=\"See less\">\n      {body}\n    </Truncate>\n  );\n}\n```\n\nStyle the generated buttons via the `[data-truncate=\"toggle\"]` attribute (the\nlibrary ships zero CSS otherwise).\n\nNeed full control over the markup and placement? Use the compound API:\n\n```tsx\nimport { Truncate } from \"@apollosuny/react-truncate\";\n\nexport function Post({ body }: { body: string }) {\n  return (\n    <Truncate lines={3}>\n      <Truncate.Content\n        ellipsis=\"... \"\n        more={(toggle) => (\n          <button onClick={toggle} className=\"font-semibold text-blue-600\">\n            See more\n          </button>\n        )}\n        less={(toggle) => (\n          <button onClick={toggle} className=\"font-semibold text-blue-600\">\n            See less\n          </button>\n        )}\n      >\n        {body}\n      </Truncate.Content>\n    </Truncate>\n  );\n}\n```\n\n> `more` sits inline at the end of the last clipped line; `less` sits inline at\n> the end of the full text once expanded. Prefer these over `<Truncate.Toggle>`\n> when you want the control on the same line as the text. Use `<Truncate.Toggle>`\n> when you want a separate, block-level control instead.\n\n---\n\n## API\n\n### `<Truncate>`\n\nRoot provider. Renders a `<div>` by default.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| `lines` | `number` | `3` | Maximum lines before truncation |\n| `expanded` | `boolean` | — | Controlled expanded state |\n| `defaultExpanded` | `boolean` | `false` | Initial state (uncontrolled) |\n| `onExpandedChange` | `(expanded: boolean) => void` | — | Fired on every toggle |\n| `ellipsis` | `ReactNode` | `\"... \"` | **Shorthand mode only** — forwarded to the auto-rendered `Content` |\n| `moreLabel` | `ReactNode` | — | **Shorthand mode only** — see `<Truncate.Content>` |\n| `lessLabel` | `ReactNode` | — | **Shorthand mode only** — see `<Truncate.Content>` |\n\nAccepts all `<div>` props. Exposes `data-state=\"truncated\"` or `data-state=\"expanded\"` for CSS targeting.\n\n> **Shorthand mode.** When `children` is a plain string, the root renders a\n> `<Truncate.Content>` for you and forwards `ellipsis` / `moreLabel` /\n> `lessLabel`. Pass `<Truncate.Content>` / `<Truncate.Toggle>` children\n> explicitly to opt into the full compound API instead (these props are ignored\n> then).\n\n---\n\n### `<Truncate.Content>`\n\nThe text container. Renders a block `<span>`.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| `children` | `string` | **required** | Plain string to truncate (see note below) |\n| `ellipsis` | `ReactNode` | `\"... \"` | Rendered before `more` at the cutoff point |\n| `more` | `(toggle: () => void) => ReactNode` | — | Inline element placed at the end of the last truncated line |\n| `less` | `(toggle: () => void) => ReactNode` | — | Inline element placed at the end of the full text once expanded |\n| `moreLabel` | `ReactNode` | — | Shorthand for `more`: renders a default accessible inline `<button>`. Ignored when `more` is set |\n| `lessLabel` | `ReactNode` | — | Shorthand for `less`. Ignored when `less` is set |\n\nAccepts all `<span>` props.\n\n> **Labels vs render-props.** `moreLabel` / `lessLabel` render a default\n> `<button data-truncate=\"toggle\">` with `onClick`, `aria-expanded`, and\n> `aria-controls` wired automatically — the zero-config path. Use `more` /\n> `less` when you need custom markup; they take precedence over the labels.\n\n> **Plain text only.** `children` must be a string — measurement is done with\n> `canvas.measureText()`, which can't measure arbitrary JSX. For rich content\n> (links, mentions, emoji rendered as nodes), this library is not the right fit.\n\n---\n\n### `<Truncate.Toggle>`\n\nA separate, block-level control rendered outside the truncated text. Hidden automatically when the text is not truncated and not expanded. For an _inline_ control, use the `more` / `less` props on `<Truncate.Content>` instead.\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| `children` | `ReactNode \\| (state: { expanded: boolean }) => ReactNode` | **required** | Label or render-prop |\n| `asChild` | `boolean` | `false` | Merges props onto the child element instead of rendering a `<button>` |\n| `contentId` | `string` | — | Override the `aria-controls` target. Defaults to the `<Truncate.Content>` id automatically |\n\nSets `aria-expanded` and `aria-controls` automatically — `aria-controls` points at the `<Truncate.Content>` region by default, with no wiring required.\n\n---\n\n### `useTruncate()`\n\nAccess the truncation context from any component nested inside `<Truncate>`.\n\n```tsx\nimport { useTruncate } from \"@apollosuny/react-truncate\";\n\nfunction CustomBadge() {\n  const { isTruncated, expanded } = useTruncate();\n  if (!isTruncated || expanded) return null;\n  return <span className=\"text-xs text-gray-400\">truncated</span>;\n}\n```\n\n| Field | Type | Description |\n|---|---|---|\n| `expanded` | `boolean` | Current expanded state |\n| `isTruncated` | `boolean` | Whether the text is actually clipped |\n| `lines` | `number` | The configured line limit |\n| `toggle` | `() => void` | Toggle expanded state |\n\n---\n\n## Patterns\n\n### Facebook-style — inline \"See more\" / \"See less\"\n\nThe `more` prop places a clickable element at the end of the last clipped line; `less` places one at the end of the full text once expanded. Both stay on the same line as the text.\n\n```tsx\n<Truncate lines={3}>\n  <Truncate.Content\n    ellipsis=\"... \"\n    more={(toggle) => (\n      <button onClick={toggle} className=\"font-semibold text-blue-600\">\n        See more\n      </button>\n    )}\n    less={(toggle) => (\n      <button onClick={toggle} className=\"font-semibold text-blue-600\">\n        See less\n      </button>\n    )}\n  >\n    {text}\n  </Truncate.Content>\n</Truncate>\n```\n\n### Block-level toggle below the text\n\nUse `<Truncate.Toggle>` when you want the control on its own line instead of inline. It renders a `<button>` (or any element via `asChild`) and wires up `aria-expanded` / `aria-controls` automatically.\n\n```tsx\n<Truncate lines={3}>\n  <Truncate.Content\n    ellipsis=\"... \"\n    more={(toggle) => (\n      <button onClick={toggle} className=\"font-semibold text-blue-600\">\n        See more\n      </button>\n    )}\n  >\n    {text}\n  </Truncate.Content>\n\n  <Truncate.Toggle className=\"mt-1 font-semibold text-blue-600\">\n    {({ expanded }) => (expanded ? \"See less\" : null)}\n  </Truncate.Toggle>\n</Truncate>\n```\n\n### Expand-only (no collapse)\n\nOmit `Truncate.Toggle` entirely. Once expanded, the text stays expanded.\n\n```tsx\n<Truncate lines={3}>\n  <Truncate.Content\n    ellipsis=\"... \"\n    more={(toggle) => (\n      <button onClick={toggle} className=\"text-blue-600\">\n        Show more\n      </button>\n    )}\n  >\n    {text}\n  </Truncate.Content>\n</Truncate>\n```\n\n### Controlled state\n\nDrive the expanded state from outside the component.\n\n```tsx\nconst [open, setOpen] = useState(false);\n\n<Truncate lines={3} expanded={open} onExpandedChange={setOpen}>\n  <Truncate.Content\n    more={(toggle) => <button onClick={toggle}>See more</button>}\n  >\n    {text}\n  </Truncate.Content>\n  <Truncate.Toggle>\n    {({ expanded }) => (expanded ? \"See less\" : null)}\n  </Truncate.Toggle>\n</Truncate>\n```\n\n### Custom ellipsis\n\n```tsx\n<Truncate lines={2}>\n  <Truncate.Content\n    ellipsis=\" \"\n    more={(toggle) => <button onClick={toggle}>[read more]</button>}\n  >\n    {text}\n  </Truncate.Content>\n</Truncate>\n```\n\n### Toggle as a custom element (`asChild`)\n\n```tsx\n<Truncate.Toggle asChild>\n  {({ expanded }) =>\n    expanded ? <a href=\"#\">See less</a> : null\n  }\n</Truncate.Toggle>\n```\n\n### CSS `data-state` targeting\n\n```tsx\n<Truncate lines={3} className=\"post-body\">\n  {/* ... */}\n</Truncate>\n```\n\n```css\n.post-body[data-state=\"truncated\"] { border-left: 3px solid orange; }\n.post-body[data-state=\"expanded\"]  { border-left: 3px solid green; }\n```\n\n### With Tailwind CSS\n\n```tsx\n<Truncate lines={4}>\n  <Truncate.Content\n    className=\"text-gray-700 leading-relaxed\"\n    more={(toggle) => (\n      <button\n        onClick={toggle}\n        className=\"font-semibold text-blue-600 hover:underline\"\n      >\n        see more\n      </button>\n    )}\n  >\n    {article.body}\n  </Truncate.Content>\n\n  <Truncate.Toggle className=\"mt-2 text-sm font-semibold text-blue-600 hover:underline\">\n    {({ expanded }) => (expanded ? \"see less\" : null)}\n  </Truncate.Toggle>\n</Truncate>\n```\n\n---\n\n## Comparison\n\n### vs CSS `line-clamp` / `-webkit-line-clamp`\n\nCSS line clamping is the right default when you only need to cap lines with a\ntrailing `…`. Reach for this library when that isn't enough:\n\n| | CSS `line-clamp` | This library |\n|---|---|---|\n| Standardization | Unprefixed form still a Working Draft; only `-webkit-line-clamp` resolves everywhere (not Baseline) | JS, works wherever `Canvas` + `ResizeObserver` do (≈ every browser since 2020) |\n| Inline \"see more\" at the exact cutoff | ✗ (clamps text only; no room for a trailing control on the same line) | ✓ |\n| Cutoff accuracy | Approximate near the boundary | Pixel-accurate (`measureText` + binary search) |\n| `isTruncated` detection | ✗ | ✓ via `useTruncate()` |\n| Custom ellipsis node | ✗ | ✓ |\n| Cost | Zero JS | Measurement runs on the client |\n\nIf you don't need the inline toggle, exact cutoff, or truncation detection,\nplain CSS is lighter — use it.\n\n### vs JSX-truncating libraries (`react-truncate-markup`, `@re-dev/react-truncate`)\n\nThose measure rendered DOM, so they can truncate **arbitrary JSX** (links,\nmentions, badges). This library measures with `canvas.measureText()`, which is\nfaster and pixel-exact but **plain-string only**. Choose based on content:\n\n- **Rich/JSX content** (inline links, formatted spans) → use a DOM-measuring\n  library.\n- **Plain text** (post bodies, descriptions, comments) with an exact inline\n  toggle and zero-CSS styling → this library.\n\n## How it works\n\n1. A `ResizeObserver` watches the container and reads its exact pixel width via `getBoundingClientRect()`.\n2. `window.getComputedStyle()` captures the element's font (family, size, weight, style) and `letter-spacing`.\n3. A hidden `<canvas>` runs `measureText()` to determine character widths, with a manual correction for `letter-spacing` (which the Canvas API ignores).\n4. For each line up to `lines - 1`, a binary search over words finds the last word that fits.\n5. On the final line, a binary search over characters finds the exact cutoff point, leaving room for `ellipsis` and `more`.\n6. When the container resizes — or when web fonts finish loading — steps 1–5 repeat automatically.\n\nThis approach is accurate across any font, size, or container width — unlike `-webkit-line-clamp`, which produces slightly wrong results near the breakpoint and cannot accommodate an inline toggle element.\n\n---\n\n## Accessibility\n\n- **Full text for screen readers.** While collapsed, the visible text is a clipped fragment of a sentence. That fragment is hidden from assistive tech (`aria-hidden`), and the **complete text** is exposed via a visually-hidden copy — so screen-reader users get the whole content, not a cut-off phrase.\n- **Focusable controls stay announced.** The inline `more` / `less` toggle is rendered outside the `aria-hidden` region, so it remains reachable by keyboard and screen readers.\n- **Wired disclosure semantics.** `<Truncate.Toggle>` sets `aria-expanded` and points `aria-controls` at the `<Truncate.Content>` region automatically.\n- **Zero CSS dependency.** The visually-hidden styles are inlined; you don't need a global `.sr-only` utility.\n\n> If you wire up an inline `more` / `less` button yourself, add `aria-expanded={expanded}` to it for full disclosure semantics. `<Truncate.Toggle>` does this for you.\n\n---\n\n## Browser support\n\nAll modern browsers (Chrome ≥ 79, Firefox ≥ 69, Safari ≥ 13.1, Edge ≥ 79).  \nRequires `ResizeObserver` and `HTMLCanvasElement` — both universally supported since 2020.\n\n---\n\n## License\n\nMIT © [apollosuny](https://github.com/apollosuny)\n","readmeFilename":"README.md"}