Four complete techniques — pure CSS line-clamp, Vanilla JS, jQuery, and a reusable React component. Every technique includes a live demo, overflow detection explained, accessible markup, and edge-case pitfalls — all with copy-ready code.
View Demo: Detect Overflow Content & Add Read More Button
📋 Table of Contents
- Why Truncate Multiline Text?
- How CSS
line-clampWorks — A Deep Dive - Overflow Detection:
scrollHeightvsclientHeight - Technique 1 — Pure CSS (No JavaScript)
- Technique 2 — Vanilla JavaScript + CSS
- Technique 3 — jQuery Version
- Technique 4 — Reusable React Component
- Bonus: Animated Expand / Collapse
- Real-World Demo: Card Grid
- Accessibility — Don’t Skip This
- Which Technique Should You Use?
- Common Pitfalls & Edge Cases
- FAQ
Why Truncate Multiline Text?
Card grids, blog feeds, comment sections, product descriptions — text length is almost always unpredictable. A blog excerpt might be 15 words or 300. A product description could be a sentence or five paragraphs. Without a truncation strategy, your layouts collapse into uneven card heights, broken grids, and walls of text that users scroll straight past.
The standard pattern: clamp visible text to a fixed number of lines, show an ellipsis … where the text is cut, and provide a Read more button that expands to the full content when the user is genuinely interested. This pattern is used on:
- Blog listing pages (post excerpt cards)
- E-commerce product description panels
- Review and comment feeds
- Social media-style content timelines
- FAQ accordions and knowledge base entries
- Any dashboard widget with user-generated or CMS-driven content
How CSS line-clamp Works — A Deep Dive
The -webkit-line-clamp property sounds simple, but most developers hit confusing bugs because it depends on three properties working together. Here’s exactly what each one does and why all three are required:
.truncated {
/* 1. Switch to the old multi-line flexbox spec.
This is the display mode that line-clamp hooks into.
Without it, -webkit-line-clamp is completely ignored. */
display: -webkit-box;
/* 2. Tell the webkit-box to stack children vertically.
This is what makes the line-counting work correctly. */
-webkit-box-orient: vertical;
/* 3. The actual clamp. Limit to N text lines.
The browser automatically appends "…" at the cut point. */
-webkit-line-clamp: 3;
/* 4. Required to hide the content that extends beyond the clamp.
Without overflow: hidden, all lines are still visible. */
overflow: hidden;
}
-webkit- prefix is misleading. Despite its name, -webkit-line-clamp is now a fully ratified CSS standard supported in every modern browser — Chrome, Firefox, Safari, Edge, and Opera — with global browser support above 99%. The prefix stuck because the unprefixed line-clamp spec was published later. You don’t need a fallback or a polyfill.How the Browser Counts Lines
The browser counts rendered lines — not characters, not words, not <br> tags. This means the clamp point responds to:
- Container width (narrower container = more lines for the same text)
- Font size and line-height
- Window / viewport resizing
- Dynamic content injection (text added after page load)
This responsiveness is a feature — but it also means your overflow detection and toggle button visibility need to update when these change.
How to Expand (Uncollapse) the Clamped Text
This is where most tutorials mislead people. Setting -webkit-line-clamp: unset or none doesn’t work reliably across all browsers. The correct solution is to break out of the -webkit-box display model entirely by switching display to block:
/* CLAMPED (default) */
.clamp-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
/* EXPANDED — toggled by JavaScript adding this class.
Switching to display:block exits the webkit-box model,
so the browser ignores -webkit-line-clamp entirely. */
.clamp-text.is-expanded {
display: block;
overflow: visible;
}
Overflow Detection: scrollHeight vs clientHeight
Before you can show a “Read more” button, you need to know whether the text actually overflows. A short description that fits in 2 lines doesn’t need a button. Only render the button when text genuinely gets clipped.
The browser exposes two properties on every DOM element:
scrollHeight— the element’s total content height, including the portion hidden by overflow. This is what the element needs to be.clientHeight— the element’s visible height, constrained by CSS. This is what the element is.
/**
* Returns true if the element's content is taller than its visible area.
* The +1 tolerance avoids false-positives from sub-pixel rounding.
*/
function isOverflowing(el) {
return el.scrollHeight > el.clientHeight + 1;
}
// Usage
const el = document.getElementById('content');
const showButton = isOverflowing(el); // true or false
isOverflowing() on DOMContentLoaded but your web font hasn’t rendered yet, the browser uses a system fallback font — which often has different character widths and line heights, giving you incorrect measurements. Use document.fonts.ready.then(() => initClamp()) instead.Technique 1 — Pure CSS
When you only need the ellipsis and no Read More button — for example, a blog card grid where the preview is sufficient — pure CSS is the cleanest possible solution. Zero JavaScript, zero dependencies.
<!-- HTML: apply the class you need -->
<p class="clamp-3">Your long text goes here...</p>
<p class="clamp-5">A longer excerpt that clamps at 5 lines...</p>
/* CSS — shared base + utility classes */
[class^="clamp-"] {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
.clamp-2 { -webkit-line-clamp: 2; }
.clamp-3 { -webkit-line-clamp: 3; }
.clamp-4 { -webkit-line-clamp: 4; }
.clamp-5 { -webkit-line-clamp: 5; }
Live Demo — CSS Only (3 lines, no JS)
☝ Clamped at 3 lines by CSS alone. Resize the window to watch it reflow.
Technique 2 — Vanilla JavaScript
The recommended approach for any plain HTML/CSS project in 2025. No dependencies, handles multiple elements on a page, and takes fewer than 35 lines of clear, commented JavaScript.
<div class="clamp-container">
<p class="clamp-text" id="para-1">
Your long text content here. Can be user-generated, CMS-driven,
or pulled from an API. Length doesn't matter — the script handles it.
</p>
<div class="clamp-btn-row" hidden>
<!-- hidden until overflow is detected -->
<button
type="button"
class="clamp-btn"
aria-expanded="false"
aria-controls="para-1"
>Read more</button>
</div>
</div>
.clamp-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3; /* change to match your design */
overflow: hidden;
line-height: 1.7;
}
/* Expanded state: exit the -webkit-box model */
.clamp-text.is-expanded {
display: block;
overflow: visible;
}
.clamp-btn-row {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.clamp-btn {
background: none;
border: none;
color: #7c3aed;
font-size: 14px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
padding: 4px 0;
}
.clamp-btn:focus-visible {
outline: 2px solid #7c3aed;
outline-offset: 3px;
border-radius: 3px;
}
/**
* initClamp(selector)
* Runs overflow detection on every .clamp-container matching the selector.
* Shows the Read More button only where content genuinely overflows.
* Each container manages its own independent state.
*/
function initClamp(selector = '.clamp-container') {
const containers = document.querySelectorAll(selector);
containers.forEach((container) => {
const text = container.querySelector('.clamp-text');
const btnRow = container.querySelector('.clamp-btn-row');
const btn = container.querySelector('.clamp-btn');
if (!text || !btnRow || !btn) return;
// ── Step 1: Detect overflow ──────────────────────────────────
// scrollHeight = full content height (including hidden portion)
// clientHeight = visible height constrained by line-clamp
// +1 tolerance absorbs sub-pixel rounding across browsers
if (text.scrollHeight <= text.clientHeight + 1) return; // fits — done
// ── Step 2: Reveal the button ────────────────────────────────
btnRow.hidden = false;
// ── Step 3: Wire the toggle ──────────────────────────────────
btn.addEventListener('click', () => {
const expanded = text.classList.contains('is-expanded');
text.classList.toggle('is-expanded', !expanded);
btn.textContent = expanded ? 'Read more' : 'Show less';
btn.setAttribute('aria-expanded', String(!expanded));
// When collapsing, scroll container into view so user isn't lost
if (expanded) {
container.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
});
}
// Run after fonts load (not just DOMContentLoaded) for accurate measurements
document.fonts.ready.then(() => initClamp('.clamp-container'));
Live Demo — Vanilla JS Read More Toggle
The JavaScript checks whether scrollHeight is greater than clientHeight — the element’s full content height versus its visible height bounded by the CSS clamp. If there’s overflow, the “Read more” button below appears automatically. Clicking it toggles the is-expanded class, which switches the element from display: -webkit-box to display: block, releasing the clamp and revealing all the content. The button label flips between “Read more” and “Show less” to keep the user oriented at all times. Every detail is handled accessibly — the button carries aria-expanded so screen readers can announce the toggle state correctly.
Technique 3 — jQuery
For WordPress themes, legacy codebases, or any project where jQuery is already loaded, the jQuery version follows the same logic with familiar syntax. Note that overflow detection still requires accessing raw DOM properties — jQuery’s own height methods won’t measure hidden overflow correctly here.
$(document).ready(function () {
$('.clamp-container').each(function () {
var $container = $(this);
var $text = $container.find('.clamp-text');
var $btnRow = $container.find('.clamp-btn-row');
var $btn = $container.find('.clamp-btn');
// Access the raw DOM element via [0] for scrollHeight / clientHeight.
// jQuery wraps elements — these properties don't exist on jQuery objects.
var el = $text[0];
if (el.scrollHeight <= el.clientHeight + 1) return; // text fits — skip
$btnRow.show();
$btn.on('click', function () {
var expanded = $text.hasClass('is-expanded');
$text.toggleClass('is-expanded', !expanded);
$btn
.text(expanded ? 'Read more' : 'Show less')
.attr('aria-expanded', String(!expanded));
if (expanded) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
});
});
Technique 4 — Reusable React Component
In a React or Next.js project, the pattern becomes a self-contained component with its own state. The key improvement over plain JS: a ResizeObserver automatically re-checks overflow when the container resizes, so the button correctly appears and disappears as the viewport changes — no manual window resize listener needed.
import { useState, useRef, useEffect } from 'react';
/**
* <ReadMore> — Multiline truncation with accessible Read More toggle
*
* Props:
* children {ReactNode} Content to truncate
* lines {number} Lines before clamp (default: 3)
* readMoreText {string} Collapsed label (default: "Read more")
* showLessText {string} Expanded label (default: "Show less")
* className {string} Optional wrapper class
*/
export default function ReadMore({
children,
lines = 3,
readMoreText = 'Read more',
showLessText = 'Show less',
className = '',
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const textRef = useRef(null);
useEffect(() => {
const el = textRef.current;
if (!el) return;
const checkOverflow = () => {
// Must temporarily remove is-expanded to get the clamped height
const wasExpanded = el.classList.contains('is-expanded');
el.classList.remove('is-expanded');
const overflows = el.scrollHeight > el.clientHeight + 1;
if (wasExpanded) el.classList.add('is-expanded');
setIsOverflowing(overflows);
};
checkOverflow();
// Re-check whenever the element's size changes (window resize, etc.)
const ro = new ResizeObserver(checkOverflow);
ro.observe(el);
return () => ro.disconnect(); // cleanup on unmount
}, [children, lines]);
const toggle = () => setIsExpanded(prev => !prev);
const textStyle = isExpanded
? { display: 'block' }
: {
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: lines,
overflow: 'hidden',
lineHeight: '1.7',
};
return (
<div className={`read-more ${className}`}>
<div ref={textRef} style={textStyle}>
{children}
</div>
{isOverflowing && (
<div style={{ display:'flex', justifyContent:'flex-end', marginTop:'8px' }}>
<button
type="button"
onClick={toggle}
aria-expanded={isExpanded}
style={{
background:'none', border:'none',
color:'#7c3aed', fontWeight:'600',
fontSize:'14px', cursor:'pointer',
fontFamily:'inherit', padding:'4px 0',
}}
>
{isExpanded ? showLessText : readMoreText}
</button>
</div>
)}
</div>
);
}
Usage
import ReadMore from './ReadMore';
export default function App() {
return (
<div>
{/* Default: 3 lines */}
<ReadMore>
Your long paragraph here. The component detects overflow automatically
and only shows the button when content actually exceeds 3 lines.
</ReadMore>
{/* 5 lines with custom labels */}
<ReadMore lines={5} readMoreText="Show full description" showLessText="Collapse">
A longer product description clamped at 5 lines...
</ReadMore>
{/* Short text — button won't appear */}
<ReadMore>Short text. No button needed.</ReadMore>
</div>
);
}
Bonus: Animated Expand / Collapse with max-height
If you want a smooth height animation rather than an instant toggle, use the max-height trick. You can’t animate height: auto directly in CSS, but you can animate max-height:
.clamp-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
line-height: 1.7;
/* max-height animation: collapsed = 3 lines × line-height */
max-height: calc(1.7em * 3);
transition: max-height 0.4s ease;
}
.clamp-text.is-expanded {
display: block;
overflow: hidden; /* keep hidden while max-height animates */
/* Large enough to hold any realistic content block */
max-height: 2000px;
transition: max-height 0.5s ease;
}
max-height trick works but has one quirk: the animation speed appears inconsistent because the transition always spans the full max-height range, regardless of actual content height. A 50px collapse will animate at the same duration as a 1800px collapse. For precise animation, use a JavaScript approach that measures actual height and sets an inline max-height value before starting the transition.Real-World Demo: Card Grid with Independent Toggles
Each card manages its own state independently. Cards with short text show no button; cards with long text show the toggle. Expanding one card has zero effect on the others.
Short description that fits easily within three lines. No button needed here.
This card has a much longer description to demonstrate overflow detection in action. When the JavaScript runs and detects that scrollHeight exceeds clientHeight — set by the CSS line-clamp — it reveals the Read More button below. Click it to expand the full text, then Show Less to collapse it back.
Another long card description showing that each card is fully independent. Expanding this one does not affect the others. This is critical for card grids driven by a CMS or API, where text length is unpredictable and every card must self-manage its own collapsed and expanded state without any global state or shared variables.
Accessibility — Don’t Skip This
A Read More toggle that keyboard and screen reader users can’t interact with is broken by any modern accessibility standard. These requirements are non-negotiable:
- Always use
<button type="button">— never a<span>,<div>, or<a>withouthref. Native buttons are keyboard-focusable, activated by both Enter and Space, and announced as interactive by screen readers for free. aria-expandedon the button communicates toggle state. Screen readers announce “Read more, collapsed, button” or “Show less, expanded, button” — users always know what clicking will do.aria-controlslinks the button to the content element it controls, allowing screen reader users to jump directly to it.- Visible focus indicator — use
:focus-visibleto show a clear outline for keyboard users without affecting mouse users. Never setoutline: nonewithout a proper replacement. - Button label changes in the DOM, not just visually — screen readers re-read the button text after a click, so “Show less” must be in the actual DOM, not just hidden by CSS opacity or transform.
<span> elements for the button. Spans are not focusable by keyboard, not announced as interactive, and cannot be activated by pressing Enter or Space. This is a WCAG 2.1 Level A failure (Success Criterion 2.1.1). Always use <button>.Which Technique Should You Use?
| Technique | Dependencies | Read More toggle | Auto resize-aware | Best for |
|---|---|---|---|---|
| Pure CSS | None | ✗ No | ✓ (browser) | Read-only card previews, no toggle needed |
| Vanilla JS | None | ✓ Yes | Manual resize listener | Any plain HTML/CSS project — recommended default |
| jQuery | jQuery (~90KB) | ✓ Yes | Manual resize listener | WordPress themes, legacy projects with jQuery loaded |
| React | React | ✓ Yes | ✓ ResizeObserver | React / Next.js apps, component reuse across codebase |
Common Pitfalls & Edge Cases
Font not loaded when detection runs
Overflow detection on DOMContentLoaded may use fallback fonts with different metrics. Always run inside document.fonts.ready.then() for accurate measurements.
Breaks inside flex/grid children
-webkit-box conflicts with some flex contexts. Add min-width: 0 to the flex child, or wrap the clamped element in a non-flex <div>.
1px false-positives
Sub-pixel rendering can make scrollHeight report 1px more than clientHeight even for text that fits. Use scrollHeight > clientHeight + 1 to absorb rounding differences.
Collapse leaves user scrolled mid-page
After collapsing long content, the button may be off-screen above. Call container.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) after collapsing to bring the container back into view.
Mixed content (text + images)
line-clamp counts text lines only and behaves unpredictably with images inside the element. For mixed content, use max-height: Xpx; overflow: hidden with a bottom gradient fade instead.
Responsive overflow mismatch
Text that fits in 3 lines on desktop may overflow on a 375px phone. Add a ResizeObserver (or debounced window resize listener) to re-run overflow detection when the container’s width changes.
FAQ
How do I truncate multiline text with CSS only?
Apply four CSS properties to the element: display: -webkit-box, -webkit-box-orient: vertical, -webkit-line-clamp: N (your desired line count), and overflow: hidden. All four are required together. This works in Chrome, Firefox, Safari, and Edge without any JavaScript.
How does -webkit-line-clamp work?
It limits the element to the specified number of rendered lines and automatically appends an ellipsis at the truncation point. It operates inside the display: -webkit-box model, which is why that display value is also required. Despite the -webkit- prefix, it is now a W3C standard with universal browser support.
How do I detect if text is overflowing its container?
Compare the element’s scrollHeight (total content height, including hidden overflow) against its clientHeight (visible height bounded by CSS). If scrollHeight > clientHeight + 1, the content is overflowing and a Read More button should be shown. The +1 absorbs sub-pixel rounding to avoid false-positives.
Why switch to display: block to expand instead of removing line-clamp?
The -webkit-line-clamp property only works while the element is in display: -webkit-box mode. Setting display: block exits that display model entirely, causing the browser to ignore line-clamp and render the element at its natural full height. Setting -webkit-line-clamp: unset alone does not reliably work across all browsers.
Can I animate the expand and collapse height?
Yes, using the max-height transition trick. Set max-height to the clamped height (e.g., calc(1.7em * 3)) when collapsed, and to a large value like 2000px when expanded, with transition: max-height 0.4s ease. You cannot animate height from auto in CSS.
Summary — Pick the Right Tool
Multiline truncation looks simple but has real nuance once you account for dynamic content, fonts, responsiveness, and accessibility. Here’s the one-line decision rule for each context:
- Ellipsis only, no toggle: three lines of CSS with
line-clamputility classes — done. - HTML/CSS project with toggle: use
initClamp()in vanilla JS — no dependencies, works on multiple elements, runs after fonts load. - jQuery already in the project: the jQuery version with the same pattern — but access
el.scrollHeighton the raw DOM element, not a jQuery method. - React or Next.js: drop in the
<ReadMore>component — ResizeObserver handles responsive re-checks automatically and cleanup is built in. - Every version: use
<button>, setaria-expanded, add a:focus-visibleoutline, and run detection afterdocument.fonts.ready.
Follow onlyWebPro.com on Facebook now for latest web development tutorials & tips updates!
