"use client";
import * as React from "react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface ReadMoreProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** The text. Paragraphs, links and inline markup are fine. */
children: React.ReactNode;
/** Number of lines shown while collapsed. */
lines?: number;
/** Expand / collapse duration in ms (0 = instant). */
duration?: number;
/** Fade the last visible line into the background while collapsed. */
fade?: boolean;
/** Clicking anywhere on the collapsed text expands it (links inside still work normally). */
expandOnClick?: boolean;
/** Button label while collapsed. */
moreLabel?: string;
/** Button label while expanded. */
lessLabel?: string;
/** Controlled expanded state. */
expanded?: boolean;
/** Initial expanded state when uncontrolled. */
defaultExpanded?: boolean;
/** Called when the reader expands or collapses the text. */
onExpandedChange?: (expanded: boolean) => void;
/** Focus ring and button hover colour (any CSS colour). */
accent?: string;
/** Extra classes for the toggle button. */
buttonClassName?: string;
}
const INTERACTIVE = "a, button, input, select, textarea, summary, [role='button'], [tabindex]";
const EASE = "cubic-bezier(0.22, 0.8, 0.24, 1)";
/** Height the element would have with the clamp applied — measured synchronously, then restored. */
function measureClamped(el: HTMLElement, lines: number) {
const saved = el.getAttribute("style");
el.style.height = "";
el.style.display = "-webkit-box";
el.style.setProperty("-webkit-box-orient", "vertical");
el.style.setProperty("-webkit-line-clamp", String(lines));
el.style.overflow = "hidden";
const height = el.getBoundingClientRect().height;
if (saved === null) el.removeAttribute("style");
else el.setAttribute("style", saved);
return height;
}
/**
* Line-clamped text with a measured-height expand/collapse. The button only appears when the
* content really overflows its clamp (checked with ResizeObserver, so it reacts to width and font
* changes). Heights are measured, never guessed: collapsing animates from the full height to the
* exact clamped height, and the clamp (with its ellipsis) is only applied once the motion ends.
*/
export function ReadMore({
children,
lines = 3,
duration = 380,
fade = true,
expandOnClick = false,
moreLabel = "Read more",
lessLabel = "Show less",
expanded: expandedProp,
defaultExpanded = false,
onExpandedChange,
accent = "#FF7448",
buttonClassName = "",
className = "",
style,
...props
}: ReadMoreProps) {
const reduceMotion = usePrefersReducedMotion();
const contentId = React.useId();
const contentRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const animationRef = React.useRef<Animation | null>(null);
const [inner, setInner] = React.useState(defaultExpanded);
const open = expandedProp ?? inner;
const [overflows, setOverflows] = React.useState(false);
const [animating, setAnimating] = React.useState(false);
const instant = reduceMotion || duration <= 0;
// Whenever `open` flips (internally or from a controlled prop), start an animation phase.
const [prevOpen, setPrevOpen] = React.useState(open);
if (prevOpen !== open) {
setPrevOpen(open);
setAnimating(!instant);
}
const clamped = !open && !animating;
// Run the height animation for the phase that was just committed.
const skipFirst = React.useRef(true);
React.useLayoutEffect(() => {
if (skipFirst.current) {
skipFirst.current = false;
return;
}
const el = contentRef.current;
if (!el) return;
if (!animating) {
animationRef.current?.cancel();
animationRef.current = null;
return;
}
// Start from wherever the content is right now: mid-animation if the reader clicked twice,
// otherwise the clamped height (expanding) or the full height (collapsing). The clamp is
// already gone from the DOM at this point, so the clamped start is measured.
const from = animationRef.current || !open ? el.getBoundingClientRect().height : measureClamped(el, lines);
animationRef.current?.cancel();
const to = open ? el.scrollHeight : measureClamped(el, lines);
const animation = el.animate([{ height: `${from}px` }, { height: `${to}px` }], {
duration,
easing: EASE,
fill: "forwards",
});
animationRef.current = animation;
animation.onfinish = () => {
if (animationRef.current !== animation) return;
setAnimating(false);
if (!open) {
// Keep the toggle on screen when a long passage collapses above the viewport.
const button = buttonRef.current;
const top = button?.getBoundingClientRect().top ?? 0;
if (top < 0) window.scrollBy({ top: top - 24 });
}
};
}, [open, animating, duration, lines]);
React.useEffect(() => () => animationRef.current?.cancel(), []);
// Show the button only when the clamp actually hides something.
React.useEffect(() => {
const el = contentRef.current;
if (!el) return;
const check = () => {
if (animating) return;
// Clamped: compare the full scroll height with the visible box. Expanded: measure the clamp.
const visible = clamped ? el.clientHeight : measureClamped(el, lines);
setOverflows(el.scrollHeight > visible + 1);
};
const ro = new ResizeObserver(check);
ro.observe(el);
let disposed = false;
document.fonts?.ready.then(() => {
if (!disposed) check();
});
return () => {
disposed = true;
ro.disconnect();
};
}, [lines, clamped, animating, children]);
const setOpen = (next: boolean) => {
if (next === open) return;
if (!next) {
// Focus inside the part that is about to be hidden moves to the toggle.
const active = document.activeElement;
if (active && contentRef.current?.contains(active)) buttonRef.current?.focus({ preventScroll: true });
}
if (expandedProp === undefined) setInner(next);
onExpandedChange?.(next);
};
const showFade = fade && overflows && !open;
const fadeMask = "linear-gradient(to bottom, #000 calc(100% - var(--rm-fade)), transparent)";
const maskSize = showFade ? "100% 100%" : "100% calc(100% + var(--rm-fade))";
const maskTransition = instant ? undefined : `mask-size ${Math.round(duration * 0.8)}ms ${EASE}, -webkit-mask-size ${Math.round(duration * 0.8)}ms ${EASE}`;
const contentStyle: React.CSSProperties = {
["--rm-fade" as string]: "1.9em",
...(clamped
? { display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: lines, overflow: "hidden" }
: animating
? { overflow: "hidden" }
: null),
...(fade && overflows
? {
maskImage: fadeMask,
WebkitMaskImage: fadeMask,
maskRepeat: "no-repeat",
WebkitMaskRepeat: "no-repeat",
maskSize,
WebkitMaskSize: maskSize,
transition: maskTransition,
}
: null),
};
const clickable = expandOnClick && overflows && !open;
return (
<div {...props} className={`flex flex-col items-start ${className}`} style={{ ["--rm-accent" as string]: accent, ...style }}>
<div
ref={contentRef}
id={contentId}
data-state={open ? "expanded" : "collapsed"}
className={`w-full ${clickable ? "cursor-pointer" : ""}`}
style={contentStyle}
onFocus={(event) => {
// Keyboard focus landing on a link hidden by the clamp reveals the rest of the text.
if (!open && overflows && event.target !== event.currentTarget) setOpen(true);
}}
onClick={(event) => {
if (!clickable) return;
if ((event.target as HTMLElement).closest(INTERACTIVE)) return;
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
setOpen(true);
}}
>
{children}
</div>
{overflows && (
<button
ref={buttonRef}
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={() => setOpen(!open)}
className={`group/rm mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-full py-1 font-mono text-[11px] font-medium uppercase tracking-[0.14em] text-zinc-900 outline-hidden transition-colors hover:text-[var(--rm-accent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--rm-accent)] dark:text-zinc-100 ${buttonClassName}`}
>
<span>{open ? lessLabel : moreLabel}</span>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className={`size-3.5 transition-transform duration-300 motion-reduce:transition-none ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 6.5 8 10.5l4-4" />
</svg>
</button>
)}
</div>
);
}