Content·10.4
Show More
Height animates, text does not reflow.
The scheduler no longer re-queues a job that was cancelled while its retry timer was still pending, which is what caused duplicate webhooks on slow upstreams. Idempotency keys are now written before the first attempt rather than after it, so a crash between the two no longer produces a second delivery. Retention for delivery logs moved from seven days to thirty. The dashboard reads them from a partitioned table, so the range picker stays fast past a million rows. Two deprecated fields on the event payload were removed after a full release of warnings.
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionUsage
"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 (
<article className="rounded-[14px] border border-stone-200 bg-white p-4 dark:border-white/[0.16] dark:bg-[#1D1D1A]">
<h3 className="text-[13px] font-medium text-stone-800 dark:text-stone-100">
Release 4.2.0
</h3>
<ShowMore
lines={3}
maxHeight={240}
label="Release notes"
expanded={expanded}
onExpandedChange={setExpanded}
className="mt-2"
>
<p>{body}</p>
</ShowMore>
</article>
);
}Source
"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<HTMLDivElement | null>;
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<Metrics | null>(null);
const contentRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>(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 (
<div
className={`text-[13.5px] leading-relaxed text-stone-700 dark:text-stone-200 ${className}`}
>
<div className="relative">
<motion.div
ref={regionRef}
id={regionId}
role={scrollable ? "region" : undefined}
aria-label={scrollable ? label : undefined}
tabIndex={scrollable ? 0 : undefined}
initial={false}
animate={height === null ? {} : { height }}
transition={reduced ? INSTANT : DISCLOSE}
style={{
maxHeight: height === null ? `${lines}lh` : undefined,
overflowY: scrollable ? "auto" : "hidden",
scrollbarGutter: capped ? "stable" : undefined,
}}
className="overflow-hidden overscroll-contain rounded-[6px] outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.10] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]"
>
<div ref={contentRef}>{children}</div>
</motion.div>
<motion.div
aria-hidden
initial={false}
animate={{ opacity: veiled ? 1 : 0 }}
transition={reduced ? INSTANT : SMALL}
className="pointer-events-none absolute inset-x-0 bottom-0 h-9 bg-gradient-to-t from-white to-white/0 dark:from-stone-900 dark:to-stone-900/0"
/>
</div>
<div className="mt-2 flex h-8 items-center">
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
<button
type="button"
onClick={press}
aria-expanded={open}
aria-controls={regionId}
className={`inline-flex h-8 select-none items-center gap-2 rounded-[9px] border border-stone-200 bg-white px-2.5 text-[12.5px] font-medium text-stone-700 outline-none transition-[border-color,box-shadow] duration-150 hover:border-stone-300 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:hover:border-white/20 dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)] ${
expandable ? "" : "pointer-events-none invisible"
}`}
>
<span className="grid text-left">
<motion.span
aria-hidden={open}
className="col-start-1 row-start-1"
initial={false}
animate={{ opacity: open ? 0 : 1 }}
transition={reduced ? INSTANT : SMALL}
>
{moreLabel}
</motion.span>
<motion.span
aria-hidden={!open}
className="col-start-1 row-start-1"
initial={false}
animate={{ opacity: open ? 1 : 0 }}
transition={reduced ? INSTANT : SMALL}
>
{lessLabel}
</motion.span>
</span>
<motion.svg
aria-hidden
width="12"
height="12"
viewBox="0 0 256 256"
fill="none"
className="text-stone-500 dark:text-stone-400"
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={reduced ? INSTANT : SMALL}
>
<polyline
points="208 96 128 176 48 96"
stroke="currentColor"
strokeWidth="16"
strokeLinecap="round"
strokeLinejoin="round"
/>
</motion.svg>
</button>
</div>
</div>
);
}Props
childrenReact.ReactNodeThe content to clamp. It is laid out once at full width and never re-flowed.
lines3numberCollapsed height, in lines of the content's own computed line-height.
maxHeight320numberCeiling in px for the expanded height. Anything past it scrolls inside.
expandedbooleanControlled open state. Omit to let the component hold its own.
defaultExpandedfalsebooleanInitial open state when uncontrolled.
onExpandedChange(expanded: boolean) => voidFires on every toggle, controlled or not.
moreLabel"Show more"stringTrigger label while collapsed.
lessLabel"Show less"stringTrigger label while expanded. Shares a grid cell with moreLabel.
label"Details"stringAccessible name for the region once it becomes a scroll container.
className""stringAppended last to the outer element.