Multiline Text Truncation with Ellipsis & Read More Button

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

  1. Why Truncate Multiline Text?
  2. How CSS line-clamp Works — A Deep Dive
  3. Overflow Detection: scrollHeight vs clientHeight
  4. Technique 1 — Pure CSS (No JavaScript)
  5. Technique 2 — Vanilla JavaScript + CSS
  6. Technique 3 — jQuery Version
  7. Technique 4 — Reusable React Component
  8. Bonus: Animated Expand / Collapse
  9. Real-World Demo: Card Grid
  10. Accessibility — Don’t Skip This
  11. Which Technique Should You Use?
  12. Common Pitfalls & Edge Cases
  13. 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;
}
💡
The -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
⚠️
Run detection after fonts load, not just after DOM loads. If you run 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)

Pure CSS — zero JavaScript

Building great user interfaces means managing unpredictable content. This paragraph is intentionally long to demonstrate the CSS line-clamp property in action. The browser clips the text at exactly three lines and appends an ellipsis at the truncation point — all without a single line of JavaScript. This technique works on all modern browsers and is the lightest solution when you don’t need a toggle button.

☝ 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

Vanilla JS — Read more / Show less

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;
}
ℹ️
The 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.

Card grid — independent Read more per card
🚀
Quick Start

Short description that fits easily within three lines. No button needed here.

🎨
Advanced Styling

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.

Performance

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> without href. Native buttons are keyboard-focusable, activated by both Enter and Space, and announced as interactive by screen readers for free.
  • aria-expanded on 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-controls links the button to the content element it controls, allowing screen reader users to jump directly to it.
  • Visible focus indicator — use :focus-visible to show a clear outline for keyboard users without affecting mouse users. Never set outline: none without 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.
⚠️
The original version of this tutorial (and many others online) used <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?

TechniqueDependenciesRead More toggleAuto resize-awareBest for
Pure CSSNone✗ No✓ (browser)Read-only card previews, no toggle needed
Vanilla JSNone✓ YesManual resize listenerAny plain HTML/CSS project — recommended default
jQueryjQuery (~90KB)✓ YesManual resize listenerWordPress themes, legacy projects with jQuery loaded
ReactReact✓ Yes✓ ResizeObserverReact / 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-clamp utility 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.scrollHeight on 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>, set aria-expanded, add a :focus-visible outline, and run detection after document.fonts.ready.

Follow onlyWebPro.com on Facebook now for latest web development tutorials & tips updates!


Posted

in

by

Advertisement