## Show More — Content Height animates, text does not reflow. Docs: https://www.interior.dev/docs/show-more Reference: https://www.interior.dev/reference/show-more License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/show-more.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { ShowMore } from "@/components/interior/show-more"; export function ReleaseNote({ body }: { body: string }) { const [expanded, setExpanded] = useState(false); return (

Release 4.2.0

{body}

); } ``` ### Props - `children` (`React.ReactNode`) The content to clamp. It is laid out once at full width and never re-flowed. - `lines` (`number`) — default: `3` Collapsed height, in lines of the content's own computed line-height. - `maxHeight` (`number`) — default: `320` Ceiling in px for the expanded height. Anything past it scrolls inside. - `expanded` (`boolean`) Controlled open state. Omit to let the component hold its own. - `defaultExpanded` (`boolean`) — default: `false` Initial open state when uncontrolled. - `onExpandedChange` (`(expanded: boolean) => void`) Fires on every toggle, controlled or not. - `moreLabel` (`string`) — default: `"Show more"` Trigger label while collapsed. - `lessLabel` (`string`) — default: `"Show less"` Trigger label while expanded. Shares a grid cell with moreLabel. - `label` (`string`) — default: `"Details"` Accessible name for the region once it becomes a scroll container. - `className` (`string`) — default: `""` Appended last to the outer element. ### Behavior notes - The collapsed state is a measured pixel height, never `-webkit-line-clamp`, so no ellipsis is added to or removed from the last visible line and the paragraph breaks in exactly the same places open or shut. - The animated height is a number read from a ResizeObserver rather than `auto`, so a toggle interrupted mid-flight springs from the height the box currently has instead of snapping to a freshly measured one. - Content taller than `maxHeight` scrolls instead of expanding without bound, and the scrollbar gutter is reserved from the first paint whenever the content overflows, so the scrollbar's arrival cannot narrow the column and re-wrap the text. - Before hydration the box is clamped in CSS at `lines × 1lh`, so server-rendered markup already shows the collapsed height rather than painting the whole paragraph and collapsing it once JavaScript lands. - The trigger's two labels occupy one grid cell and its row keeps its height even when the content is short enough that no trigger is offered, so nothing below the block ever moves. - Under `prefers-reduced-motion` the height, the edge fade and the chevron all settle instantly, and the clipped text stays in the accessibility tree while collapsed, so the information arrives either way. ### Source (`components/interior/show-more.tsx`) ```tsx "use client"; import { useCallback, useId, useRef, useState } from "react"; import { motion, useIsomorphicLayoutEffect, useReducedMotion, } from "motion/react"; const DISCLOSE = { type: "spring", stiffness: 190, damping: 30, mass: 1, } as const; const SMALL = { type: "spring", stiffness: 700, damping: 46, mass: 0.5, } as const; const INSTANT = { duration: 0 } as const; type Metrics = { line: number; full: number }; export type UseShowMoreOptions = { lines?: number; maxHeight?: number; defaultExpanded?: boolean; expanded?: boolean; onExpandedChange?: (expanded: boolean) => void; }; export type UseShowMoreResult = { contentRef: React.RefObject; expanded: boolean; open: boolean; toggle: () => void; setExpanded: (next: boolean) => void; height: number | null; collapsedHeight: number | null; fullHeight: number | null; expandable: boolean; capped: boolean; scrollable: boolean; }; export function useShowMore({ lines = 3, maxHeight = 320, defaultExpanded = false, expanded: expandedProp, onExpandedChange, }: UseShowMoreOptions = {}): UseShowMoreResult { const [uncontrolled, setUncontrolled] = useState(defaultExpanded); const [metrics, setMetrics] = useState(null); const contentRef = useRef(null); const expanded = expandedProp ?? uncontrolled; const notify = useRef(onExpandedChange); notify.current = onExpandedChange; const setExpanded = useCallback( (next: boolean) => { if (expandedProp === undefined) setUncontrolled(next); notify.current?.(next); }, [expandedProp], ); const toggle = useCallback( () => setExpanded(!expanded), [setExpanded, expanded], ); useIsomorphicLayoutEffect(() => { const el = contentRef.current; if (!el) return; const read = () => { const styles = getComputedStyle(el); const parsed = Number.parseFloat(styles.lineHeight); const line = Number.isFinite(parsed) ? parsed : Number.parseFloat(styles.fontSize) * 1.5; const full = el.scrollHeight; setMetrics((prev) => prev && prev.line === line && prev.full === full ? prev : { line, full }, ); }; read(); const observer = new ResizeObserver(read); observer.observe(el); return () => observer.disconnect(); }, []); const clamped = metrics ? metrics.line * lines : 0; const expandable = metrics ? metrics.full - clamped > 1 : true; const capped = metrics ? metrics.full > maxHeight : false; const collapsedHeight = metrics ? Math.min(clamped, metrics.full) : null; const fullHeight = metrics ? Math.min(metrics.full, maxHeight) : null; const open = expanded && expandable; return { contentRef, expanded, open, toggle, setExpanded, height: open ? fullHeight : collapsedHeight, collapsedHeight, fullHeight, expandable, capped, scrollable: open && capped, }; } export type ShowMoreProps = UseShowMoreOptions & { children: React.ReactNode; moreLabel?: string; lessLabel?: string; label?: string; className?: string; }; export function ShowMore({ children, moreLabel = "Show more", lessLabel = "Show less", label = "Details", lines = 3, maxHeight = 320, defaultExpanded, expanded, onExpandedChange, className = "", }: ShowMoreProps) { const reduced = useReducedMotion(); const regionId = useId(); const regionRef = useRef(null); const { contentRef, open, toggle, height, expandable, capped, scrollable } = useShowMore({ lines, maxHeight, defaultExpanded, expanded, onExpandedChange, }); const press = () => { if (open) regionRef.current?.scrollTo({ top: 0 }); toggle(); }; const veiled = expandable && (!open || scrollable); return (
{children}
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
); } ```