Read More
Clamped text that expands to its full height smoothly, with a toggle that only appears if the text actually overflows.
npx shadcn@latest add https://sundaeui.dev/r/read-more.json'use client'
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import './read-more.css'
export interface ReadMoreProps {
lines?: number
children: ReactNode
header?: ReactNode
fill?: 'dark' | 'light'
className?: string
}
/** Line-clamped text that expands to its natural height. */
export function ReadMore({ lines = 3, children, header, fill = 'dark', className }: ReadMoreProps) {
const [open, setOpen] = useState(false)
const [clamp, setClamp] = useState(true)
const [overflow, setOverflow] = useState(false)
const [h, setH] = useState<number>()
const box = useRef<HTMLDivElement>(null)
const txt = useRef<HTMLParagraphElement>(null)
const id = useId()
useLayoutEffect(() => {
const el = txt.current
if (!el) return
setOpen(false); setClamp(true); setH(undefined)
requestAnimationFrame(() => setOverflow(el.scrollHeight > el.clientHeight + 1))
}, [lines, children])
const toggle = () => {
const el = txt.current, b = box.current
if (!el || !b) return
const from = b.offsetHeight
if (!open) {
setClamp(false)
requestAnimationFrame(() => { setH(from); requestAnimationFrame(() => setH(el.scrollHeight)) })
setOpen(true)
} else {
setH(from)
const lh = parseFloat(getComputedStyle(el).lineHeight)
requestAnimationFrame(() => setH(lh * lines))
setOpen(false)
setTimeout(() => { setClamp(true); setH(undefined) }, 360)
}
}
return (
<div className={['sd-rdm', className].filter(Boolean).join(' ')} data-fill={fill}>
{header}
<div ref={box} id={id} className="sd-rdm__body" style={{ height: h }}>
<p ref={txt} className="sd-rdm__txt" data-clamp={clamp || undefined} style={clamp ? { WebkitLineClamp: lines } : undefined}>{children}</p>
</div>
{overflow && (
<button type="button" className="sd-rdm__tg" aria-expanded={open} aria-controls={id} onClick={toggle}>
{open ? 'Show less' : 'Read more'}<svg viewBox="0 0 12 12" aria-hidden><path d="m3 4.5 3 3 3-3" /></svg>
</button>
)}
</div>
)
}.sd-rdm { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-rdm[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-rdm button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-rdm :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-rdm .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-rdm { width: min(100%, 320px); padding: 16px; border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-rdm__who { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 13px; }
.sd-rdm__av { width: 30px; height: 30px; border-radius: 99px; display: grid; place-items: center; background: var(--soft); font-weight: 600; font-size: 12px; }
.sd-rdm__who span { color: var(--mute); }
.sd-rdm__body { overflow: hidden; transition: height 360ms cubic-bezier(0.3, 1, 0.4, 1); }
.sd-rdm__txt { margin: 0; font-size: 14px; line-height: 1.55; }
.sd-rdm__txt[data-clamp] { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
.sd-rdm__tg { margin-top: 8px; display: inline-flex; align-items: center; gap: 5px; font-size: 13px; font-weight: 600; }
.sd-rdm__tg svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: transform 300ms; }
.sd-rdm__tg[aria-expanded='true'] svg { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) { .sd-rdm *, .sd-rdm *::before, .sd-rdm *::after { animation: none !important; transition: none !important; } }Overview
The text is clamped to a set number of lines. If it fits, no toggle is shown. If it overflows, Read more animates the height open to the exact content height and flips to Show less.
No fade overlay: the last visible line ends with an ellipsis, which reads cleanly on any background.
When to use
- Reviews, bios, product descriptions, comments.
When not to use
- Critical information: never hide it behind a toggle.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/read-more.jsonnpx shadcn@latest add https://sundaeui.dev/r/read-more.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/read-more.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/read-more.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy read-more.tsx and read-more.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ReadMore } from '@/components/sundae/read-more'
<ReadMore />Props
| Prop | Type | Default |
|---|---|---|
linesLines shown when collapsed. | number | 3 |
children*The text. | ReactNode | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The toggle is a button with aria-expanded and aria-controls.
- Collapsed text is still in the DOM, so screen readers read all of it.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-rdm; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Read More free?
- Yes. Read More is free for personal and commercial projects, with full source on this page.
- How do I install Read More?
- Run npx shadcn@latest add https://sundaeui.dev/r/read-more.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Read More support keyboard and screen readers?
- The toggle is a button with aria-expanded and aria-controls. Collapsed text is still in the DOM, so screen readers read all of it.
- Does Read More respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Fold Accordion
Components · UnderstatedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined Hold to Confirm
Components · Refined