More Layouts Snippets
Read More Expand — Free HTML CSS JS Snippet
Read More Expand · Layouts · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Read More Expand — CSS max-height Clamp, Gradient Fade Overlay & Smooth Expand Transition

A read more / read less toggle lets you show a preview of long content with an expand option — keeping cards, product descriptions, and article excerpts compact without truncating text abruptly. This snippet implements the complete pattern: a CSS max-height clamp that shows exactly 3 lines, a gradient fade-out overlay that signals more content below, a smooth expand/collapse transition, and a "Read more / Read less" button with a rotating arrow indicator.
The max-height clamp approach
The content div starts with max-height: 4.5em (approximately 3 lines at 1.5em line-height × 3). CSS overflow: hidden clips any text beyond this height. The CSS transition: max-height 0.4s ease animates the expand. The expanded state sets max-height: 600px — a value safely larger than any realistic content height.
The gradient fade overlay
The .clamped::after pseudo-element applies a linear-gradient from transparent to the card background colour. This creates the impression that the text fades out naturally rather than being cut off sharply. The height is 2em, covering the last line and half of the second-to-last line. pointer-events: none prevents the overlay from intercepting clicks on the text.
Why max-height, not height: auto
CSS cannot transition from height: auto to a pixel value or vice versa. max-height solves this: transition from 4.5em (clamped) to 600px (expanded) works smoothly. The trade-off: the transition speed is not constant — it takes the same 0.4s whether the content is 5 lines or 20 lines. For most card descriptions, this is imperceptible.
The toggle logic
The toggle() function calls classList.toggle('expanded') on the content element and reads the returned boolean. This single call both adds/removes the class and tells us the new state. No separate isExpanded variable needed — the DOM class IS the state.
The rotating arrow indicator
The .rm-btn::after pseudo-element shows ↓ via CSS content. The .expanded class applies transform: rotate(180deg), turning it into ↑. The CSS transition: transform 0.3s animates the rotation in sync with the content expanding. No SVG or image needed.
Accessibility considerations
The .rm-btn should communicate its purpose to screen readers. Add aria-expanded="false" initially, toggling to "true" on expand. Add aria-controls="rm1-content" pointing to the content element ID. Update in the toggle() function: btn.setAttribute("aria-expanded", isExpanded). The gradient overlay has pointer-events: none so keyboard users can still interact with any links inside the clamped text.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You do not have to puzzle out the max-height trick by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the component transitions max-height instead of height, and why the collapsed and expanded states use two very different fixed values (4.5em versus 600px) rather than something more precise. The same assistant can help you optimize it — ask how you would detect when content is already shorter than the clamp height (comparing scrollHeight to clientHeight) so the "Read more" button never shows for text that does not need it. It's also useful for extending the component: ask it to make the collapse animation duration scale with content length instead of a fixed 0.4s, add a "Read more" link that deep-links to an anchor in the full article instead of expanding inline, or support nested read-more sections inside a longer expanded block. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "read more / read less" text truncation component in plain HTML, CSS, and JavaScript with no library.
Requirements:
- Clamp the content container to approximately three lines of text using max-height (not height) with overflow hidden, since CSS cannot transition to or from an auto height value.
- Add a gradient fade overlay using a pseudo-element positioned over the bottom of the clamped content, fading from transparent to the container's background color, with pointer-events set to none so it never blocks clicks or text selection on the content beneath it.
- Add a toggle button that switches the content between the clamped state and an expanded state (a much larger fixed max-height value, safely bigger than any realistic content), animated with a CSS transition on max-height.
- Use classList.toggle's own boolean return value as the single source of truth for whether the content is expanded — do not track a separate JavaScript variable that could drift out of sync with the DOM class.
- Add a small arrow indicator on the button that rotates 180 degrees via a CSS transform transition when expanded, synced to the same toggle.
- Implement a check that compares the content element's scrollHeight to its clientHeight after render, and hides the toggle button entirely when the content is already short enough to fit without clamping.Step by step
How to Use
- 1Click "Read more" to expand the contentThe card expands with a smooth max-height animation. The gradient fade disappears. The button changes to "Read less" with the arrow flipping to point up. Click again to collapse.
- 2Adjust the clamp heightChange max-height: 4.5em on .rm-content.clamped to control how many lines show. 3em = ~2 lines, 4.5em = ~3 lines, 6em = ~4 lines. Also adjust the ::after gradient height to match the last visible line.
- 3Match the gradient to your backgroundUpdate the linear-gradient in .rm-content.clamped::after from #fff to your card background colour. For a dark card, use rgba(0,0,0,0) → your dark bg colour. Without matching, the gradient overlay is visible as a white stripe.
- 4Use for any content lengthThe snippet works with any amount of text. Short content that fits within the clamp height shows no gradient and the button is still visible — consider hiding the button when content is short: measure scrollHeight vs clientHeight and hide if equal.
- 5Hide the button when content fitsAfter the component renders, check: if (content.scrollHeight <= content.clientHeight) btn.style.display = "none". This hides the "Read more" button when the content is already fully visible within the clamp height.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component with expanded state in useState, or "Tailwind" for a React + Tailwind CSS version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
CSS cannot transition between height: auto and a fixed pixel value because "auto" is not a computed length the browser can interpolate. max-height works because both states are explicit values: 4.5em (clamped) and 600px (expanded). The browser can interpolate between these. The trade-off: the animation takes the same duration (0.4s) whether the content is 4 lines or 40 lines, because the browser interpolates from 0 to 600px regardless of actual content height. For most use cases this is not noticeable.
After the page renders, check each content element: document.querySelectorAll(".read-more-wrap").forEach(wrap => { const content = wrap.querySelector(".rm-content"); const btn = wrap.querySelector(".rm-btn"); if (content.scrollHeight <= content.clientHeight + 2) { btn.style.display = "none"; content.classList.remove("clamped"); content.style.maxHeight = "none"; } }). The +2 pixel tolerance handles rounding. The content.scrollHeight is the actual content height; clientHeight is the clamped visible height.
Call a checkClamp() function after content is inserted into the DOM. For server-rendered pages, call it in DOMContentLoaded. For dynamically loaded content (infinite scroll, API-loaded cards), call checkClamp() in the callback after the new HTML is inserted. For React, call it in useEffect when the content prop changes: useEffect(() => checkClamp(contentRef.current), [content]).
Click "JSX" to download. Manage expanded with useState(false). Apply className based on state: className={"rm-content " + (expanded ? "expanded" : "clamped")}. The button calls setExpanded(prev => !prev). To hide the button when content is short: use a contentRef with useRef, and a useEffect that checks contentRef.current.scrollHeight <= contentRef.current.clientHeight after mount to set a showButton state.