@@ -106,6 +106,7 @@ function Component() {
- **[`useClickOutside`](https://hookli.vercel.app/docs/use-click-outside)** — Runs a callback on outside click.
- **[`useMousePosition`](https://hookli.vercel.app/docs/use-mouse-position)** — Cursor coordinates within an element.
- **[`useInfiniteScroll`](https://hookli.vercel.app/docs/use-infinite-scroll)** — Triggers loading near the scroll end.
+- **[`useExpandableText`](https://hookli.vercel.app/docs/use-expandable-text)** — Collapse long text by a character and/or line budget with a show-more toggle.
- **[`useHover`](https://hookli.vercel.app/docs/use-hover)** — Tracks whether the pointer is hovering an element.
- **[`useIntersectionObserver`](https://hookli.vercel.app/docs/use-intersection-observer)** — Observe an element's viewport intersection reactively.
- **[`useResizeObserver`](https://hookli.vercel.app/docs/use-resize-observer)** — Measure an element's size reactively via ResizeObserver.
diff --git a/apps/docs/components/demos/use-expandable-text-demo.tsx b/apps/docs/components/demos/use-expandable-text-demo.tsx
new file mode 100644
index 0000000..3b2b476
--- /dev/null
+++ b/apps/docs/components/demos/use-expandable-text-demo.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { useState } from "react";
+import { useExpandableText } from "hookli";
+import { DemoButton, DemoReadout } from "./ui";
+
+const SAMPLE =
+ "hookli bundles the React hooks you reach for most into one typed, " +
+ "SSR-safe, zero-dependency package — state, effects, DOM and data helpers " +
+ "that tree-shake cleanly so you only ship what you import. No more hunting " +
+ "across the ecosystem or copy-pasting the same useDebounce for the hundredth time.";
+
+const MODES = {
+ chars: { label: "180 chars", options: { maxChars: 180 } },
+ lines: { label: "3 lines", options: { maxLines: 3 } },
+ both: { label: "both", options: { maxChars: 180, maxLines: 3 } },
+} as const;
+
+type Mode = keyof typeof MODES;
+
+/* Docs-page demo: the same paragraph collapsed three ways — a pure character
+ budget, a responsive line budget, or both at once (whichever clips first
+ wins). Narrow the viewport to watch the line-based clamp re-measure. Mirrors
+ lib/hook-docs.ts — keep in sync. */
+export function UseExpandableTextDocDemo() {
+ const [mode, setMode] = useState("both");
+
+ const { text, isExpanded, isTruncated, toggle, ref, clampStyle } =
+ useExpandableText(SAMPLE, MODES[mode].options);
+
+ return (
+
+ The character budget yields a genuinely shortened string (SSR-safe); the
+ line budget clamps via CSS and re-measures on resize. Switch to “both” and
+ narrow the window to see whichever limit clips first win.
+
+
+ );
+}
diff --git a/apps/docs/lib/hook-docs.ts b/apps/docs/lib/hook-docs.ts
index 5b0d0d2..7a775fb 100644
--- a/apps/docs/lib/hook-docs.ts
+++ b/apps/docs/lib/hook-docs.ts
@@ -13,6 +13,7 @@ import { UseDebounceValueDocDemo } from "@/components/demos/use-debounce-value-d
import { UseDocumentTitleDocDemo } from "@/components/demos/use-document-title-demo";
import { UseEventCallbackDocDemo } from "@/components/demos/use-event-callback-demo";
import { UseEventListenerDocDemo } from "@/components/demos/use-event-listener-demo";
+import { UseExpandableTextDocDemo } from "@/components/demos/use-expandable-text-demo";
import { UseFetchDocDemo } from "@/components/demos/use-fetch-demo";
import { UseFormDocDemo } from "@/components/demos/use-form-demo";
import { UseGeoLocationDocDemo } from "@/components/demos/use-geo-location-demo";
@@ -1493,6 +1494,111 @@ export function Demo() {
},
],
},
+ "use-expandable-text": {
+ demo: UseExpandableTextDocDemo,
+ usage: `
+import { useExpandableText } from "hookli";
+
+export function Review({ body }: { body: string }) {
+ const { text, isExpanded, isTruncated, toggle, ref, clampStyle } =
+ useExpandableText(body, { maxChars: 180, maxLines: 3 });
+
+ return (
+
+
{text}
+ {isTruncated && (
+
+ )}
+
+ );
+}
+`,
+ parameters: [
+ {
+ name: "text",
+ type: "string",
+ description: "The full text to (maybe) collapse.",
+ },
+ {
+ name: "options",
+ type: "UseExpandableTextOptions",
+ defaultValue: "{}",
+ description: "Character and/or line budgets and display options. Whichever limit clips first wins.",
+ },
+ ],
+ returns: [
+ {
+ name: "text",
+ type: "string",
+ description: "The text to render — character-capped when collapsed and maxChars is set, otherwise the full text.",
+ },
+ {
+ name: "isExpanded",
+ type: "boolean",
+ description: "Whether the full text is currently shown.",
+ },
+ {
+ name: "isTruncated",
+ type: "boolean",
+ description: "True when either limit actually clips the text — use it to hide the toggle when the text fits.",
+ },
+ {
+ name: "toggle",
+ type: "() => void",
+ description: "Flip between expanded and collapsed.",
+ },
+ {
+ name: "expand",
+ type: "() => void",
+ description: "Show the full text.",
+ },
+ {
+ name: "collapse",
+ type: "() => void",
+ description: "Collapse back to the limit.",
+ },
+ {
+ name: "ref",
+ type: "RefCallback",
+ description: "Attach to the text element. Required for the maxLines clamp and its overflow measurement.",
+ },
+ {
+ name: "clampStyle",
+ type: "CSSProperties",
+ description: "Spread onto the text element; applies the CSS line-clamp while collapsed and maxLines is set.",
+ },
+ ],
+ typeAliases: [
+ {
+ name: "UseExpandableTextOptions",
+ description: "Character and line budgets — provide either, or both.",
+ rows: [
+ {
+ name: "maxChars",
+ type: "number",
+ description: "Max characters shown while collapsed. Pure string logic (SSR-safe), trimmed to a word boundary.",
+ },
+ {
+ name: "maxLines",
+ type: "number",
+ description: "Max lines shown while collapsed. Applied as a CSS line-clamp and measured in the DOM, so it re-clips on resize.",
+ },
+ {
+ name: "ellipsis",
+ type: "string",
+ defaultValue: '"…"',
+ description: "Appended to character-truncated text.",
+ },
+ {
+ name: "defaultExpanded",
+ type: "boolean",
+ defaultValue: "false",
+ description: "Whether the text starts expanded.",
+ },
+ ],
+ },
+ ],
+ },
"use-hover": {
demo: UseHoverDocDemo,
usage: `
diff --git a/apps/docs/lib/hook-sources.ts b/apps/docs/lib/hook-sources.ts
index 022ade4..b44564e 100644
--- a/apps/docs/lib/hook-sources.ts
+++ b/apps/docs/lib/hook-sources.ts
@@ -1681,6 +1681,118 @@ function getCurrentPosition(): Promise {
navigator.geolocation.getCurrentPosition(resolve, reject);
});
}
+`,
+ },
+ "use-expandable-text": {
+ path: "src/hooks/use-expandable-text/use-expandable-text.ts",
+ source: `import {
+ useCallback,
+ useEffect,
+ useState,
+ type CSSProperties,
+ type RefCallback,
+} from "react";
+
+export interface UseExpandableTextOptions {
+ maxChars?: number;
+ maxLines?: number;
+ ellipsis?: string;
+ defaultExpanded?: boolean;
+}
+
+export interface UseExpandableTextResult {
+ text: string;
+ isExpanded: boolean;
+ isTruncated: boolean;
+ toggle: () => void;
+ expand: () => void;
+ collapse: () => void;
+ ref: RefCallback;
+ clampStyle: CSSProperties;
+}
+
+const truncateChars = (
+ text: string,
+ maxChars: number,
+ ellipsis: string,
+): string => {
+ if (text.length <= maxChars) return text;
+ const slice = text.slice(0, maxChars);
+ const lastSpace = slice.lastIndexOf(" ");
+ const cut = lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
+ return cut.trimEnd() + ellipsis;
+};
+
+export const useExpandableText = (
+ text: string,
+ options: UseExpandableTextOptions = {},
+): UseExpandableTextResult => {
+ const { maxChars, maxLines, ellipsis = "…", defaultExpanded = false } = options;
+
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+ const [node, setNode] = useState(null);
+ const [lineOverflow, setLineOverflow] = useState(false);
+
+ const charTruncated = maxChars !== undefined && text.length > maxChars;
+ const collapsedText = charTruncated
+ ? truncateChars(text, maxChars, ellipsis)
+ : text;
+ const displayText = isExpanded ? text : collapsedText;
+
+ const ref = useCallback>((el) => setNode(el), []);
+
+ // scrollHeight reflects the full content height regardless of the clamp, so
+ // the overflow read is accurate in both states; the char cap alone drives
+ // isTruncated when it has already shortened the string.
+ useEffect(() => {
+ if (!node || maxLines === undefined) {
+ setLineOverflow(false);
+ return;
+ }
+ const measure = () => {
+ const style = window.getComputedStyle(node);
+ let lineHeight = Number.parseFloat(style.lineHeight);
+ if (!Number.isFinite(lineHeight)) {
+ lineHeight = Number.parseFloat(style.fontSize) * 1.2;
+ }
+ const padding =
+ Number.parseFloat(style.paddingTop) +
+ Number.parseFloat(style.paddingBottom);
+ const maxHeight = lineHeight * maxLines + (padding || 0);
+ setLineOverflow(node.scrollHeight > maxHeight + 1);
+ };
+ measure();
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(measure);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [node, maxLines, text, isExpanded]);
+
+ const toggle = useCallback(() => setIsExpanded((value) => !value), []);
+ const expand = useCallback(() => setIsExpanded(true), []);
+ const collapse = useCallback(() => setIsExpanded(false), []);
+
+ const clampStyle: CSSProperties =
+ !isExpanded && maxLines !== undefined
+ ? {
+ display: "-webkit-box",
+ WebkitBoxOrient: "vertical",
+ WebkitLineClamp: maxLines,
+ overflow: "hidden",
+ }
+ : {};
+
+ return {
+ text: displayText,
+ isExpanded,
+ isTruncated: charTruncated || lineOverflow,
+ toggle,
+ expand,
+ collapse,
+ ref,
+ clampStyle,
+ };
+};
`,
},
};
diff --git a/apps/docs/public/hookli-banner.png b/apps/docs/public/hookli-banner.png
index 5825246..d1cb185 100644
Binary files a/apps/docs/public/hookli-banner.png and b/apps/docs/public/hookli-banner.png differ
diff --git a/apps/docs/public/hookli-banner.svg b/apps/docs/public/hookli-banner.svg
index 2d193b6..f634013 100644
--- a/apps/docs/public/hookli-banner.svg
+++ b/apps/docs/public/hookli-banner.svg
@@ -1,4 +1,4 @@
-