what we're building
a block of text that starts collapsed, showing only a short preview or nothing extra at all, with a clickable bit that says something like "view more". click it, and the rest of the text appears. click it again (or a "view less" that appears in its place), and it collapses back down. every method below produces this same basic result, they just differ in how much html/css/js you need to write, and how much control you get over the styling.
option 1: the native <details> tag
html actually has a built-in element for exactly this, no css or javascript required at all. it's called <details>, and it comes paired with a <summary> tag for the always-visible clickable part.
live example: click the plus sign below.
lorem ipsum dolor sit amet
lorem ipsum dolor sit amet, consectetur adipiscing elit. sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. duis aute irure dolor in reprehenderit.
<details>
<summary>lorem ipsum dolor sit amet</summary>
<p>lorem ipsum dolor sit amet, consectetur adipiscing elit. sed do eiusmod tempor incididunt...</p>
</details>
<details>: a wrapper element the browser already knows how to collapse and expand, no extra code needed. by default it renders collapsed, showing only whatever is inside its<summary><summary>: the part that's always visible and clickable, this is your post's title or preview line. clicking anywhere on it toggles the whole<details>element open and closed- everything else inside
<details>but outside<summary>, in this case the<p>, is the hidden content that only shows once it's expanded - want it to start open instead of collapsed? add the
openattribute:<details open>
the browser's default arrow marker and plain black text aren't very exciting, so a little css goes a long way here too:
details summary {
cursor: pointer;
list-style: none;
font-weight: bold;
color: #a88b98;
}
details summary::-webkit-details-marker {
display: none;
}
details summary::before {
content: "+ ";
}
details[open] summary::before {
content: "- ";
}
list-style: none: removes the default triangle marker in firefox. chrome and safari use a slightly different, non-standard marker instead, which is what the next rule targetssummary::-webkit-details-marker { display: none; }: this is a webkit-only pseudo-element (chrome, safari, edge) that specifically hides their version of the triangle marker. you need both this rule andlist-style: noneto reliably hide the marker across every browsersummary::before { content: "+ "; }: with the native marker gone, this adds your own custom symbol in its place, a plus sign here, but it could just as easily be an arrow emoji, a little icon, or nothing at alldetails[open] summary::before { content: "- "; }: this is an attribute selector, it only matches a<summary>whose parent<details>currently has theopenattribute. the browser adds and removes this attribute automatically every time it's toggled, no javascript needed, css alone can react to it
<details> is the easiest option by far, but it comes with two trade-offs. first, there's no built-in way to animate the expand/collapse, it just snaps open and shut instantly (some very recent browser versions support animating it, but support isn't consistent yet, so don't rely on it). second, you're limited to css for styling, since there's no javascript hook here to do anything more advanced with. if either of those matter to you, options 2 or 3 below are a better fit.option 2: the css-only checkbox hack
this one still uses zero javascript, but swaps <details> for a hidden checkbox and a <label> styled to look like a button. the advantage over option 1 is more control: you can animate the reveal, and you can style the button text itself.
live example: click "view more" below.
lorem ipsum dolor sit amet, consectetur adipiscing elit.
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore.
<div class="vm-post">
<p>lorem ipsum dolor sit amet, consectetur adipiscing elit...</p>
<input type="checkbox" id="post1-toggle">
<div class="vm-more">
<p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ut enim ad minim veniam...</p>
</div>
<label for="post1-toggle"></label>
</div>
<input type="checkbox" id="post1-toggle">: an ordinary checkbox, it'll be hidden with css in a moment, but it's still a real, fully working checkbox underneath. its whole job is to hold a checked/unchecked state that css can read<label for="post1-toggle"></label>: theforattribute must exactly match the checkbox'sid. this is what makes clicking the label toggle the checkbox, even though the checkbox itself is invisible, clicking any label linked to a checkbox always ticks it, that's just how labels work in html, no javascript involved- the
idvalue (post1-togglehere) needs to be unique on the page. if you have several of these posts, give each one's checkbox a different id, likepost2-toggle,post3-toggle, and so on
.vm-post input[type="checkbox"] {
display: none;
}
.vm-post .vm-more {
max-height: 0;
overflow: hidden;
transition: max-height 400ms ease;
}
.vm-post input:checked ~ .vm-more {
max-height: 300px;
}
.vm-post label {
display: inline-block;
cursor: pointer;
font-weight: bold;
color: #a88b98;
}
.vm-post label::after {
content: "view more ↓";
}
.vm-post input:checked ~ label::after {
content: "view less ↑";
}
input[type="checkbox"] { display: none; }: hides the checkbox itself, visitors never see the little tick box, only the styled label.vm-more { max-height: 0; overflow: hidden; }: this is the trick that hides the extra content. instead ofdisplay: none(which can't be animated), the hidden content is given a max height of zero and anything past that height gets clipped off byoverflow: hiddeninput:checked ~ .vm-more { max-height: 300px; }: the~is the general sibling combinator, it selects an element that comes anywhere after another one, as long as they share the same parent. so this rule means "when this specific checkbox is checked, give any.vm-moresibling that comes after it a max-height of 300px instead of 0", which is what reveals the content. this only works because of the html order: the checkbox has to come before.vm-morein the markup for the sibling selector to reach ittransition: max-height 400ms ease;: this is what makes the reveal slide open smoothly instead of snapping instantly, unlike option 1, css transitions do work onmax-heightlabel::after { content: "view more ↓"; }and the matchinginput:checked ~ label::afterrule: swaps the label's own text depending on the checkbox state, entirely in css, no javascript needed to change "view more" into "view less"
max-height: 300px value has to be a real number, css can't animate to max-height: none or auto. pick a number generously larger than your longest post is ever likely to be, if a post's real content is taller than the max-height you chose, it'll get cut off even when expanded. this is the one real downside of the checkbox hack compared to option 3 below, which doesn't have this guessing problem.option 3: javascript toggle with a smooth height animation
this version uses a small bit of javascript instead of the checkbox trick. it's slightly more code, but it fixes the "guess a max-height" problem from option 2, since javascript can measure the content's actual height and animate to that exact number every time.
live example: click "view more" below.
lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod.
tempor incididunt ut labore et dolore magna aliqua. ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
<div class="vm-post">
<p>lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod.</p>
<div class="vm-more">
<p>tempor incididunt ut labore et dolore magna aliqua. ut enim ad minim veniam...</p>
</div>
<button type="button" class="vm-toggle">view more ↓</button>
</div>
<div class="vm-more">: wraps the hidden portion of the post, same idea as option 2, just without the checkbox this time<button type="button">: always usetype="button"here, not the default. if this post ever ends up inside a<form>somewhere on your page, a button without an explicit type defaults totype="submit"and could accidentally submit that form when clicked
.vm-more {
max-height: 0;
overflow: hidden;
transition: max-height 400ms ease;
}
same collapsing setup as option 2's css, css handles the hiding and the animation, javascript only handles measuring the height and flipping a class.
const post = document.querySelector(".vm-post");
const more = post.querySelector(".vm-more");
const toggleBtn = post.querySelector(".vm-toggle");
toggleBtn.addEventListener("click", function () {
const isOpen = more.style.maxHeight;
if (isOpen) {
more.style.maxHeight = null;
toggleBtn.textContent = "view more ↓";
} else {
more.style.maxHeight = more.scrollHeight + "px";
toggleBtn.textContent = "view less ↑";
}
});
const isOpen = more.style.maxHeight;: checks whether an inlinemax-heighthas already been set by this script. an empty string here means "not open yet", any other value means it's currently expandedmore.style.maxHeight = null;: clears the inline style, letting the css rule (max-height: 0) take back over, this is what collapses it againmore.scrollHeight: this is the key piece that fixes option 2's guessing problem.scrollHeightreturns an element's actual full content height in pixels, including the part that's currently clipped off and invisible. settingmax-heightto exactly that number (plus "px") means the animation always ends at precisely the right height, no matter how long or short the content is, and it works correctly even if you add or edit posts latertoggleBtn.textContent = "...": swaps the button's visible text between the two states, since this is plain javascript now instead of a css::aftertrick
aria-expanded attribute on the button, screen readers announce this so visitors using one know whether the content is currently shown or hidden: toggleBtn.setAttribute("aria-expanded", isOpen ? "false" : "true"), added right alongside the text swap above.having more than one post on the page
the snippet above uses document.querySelector(".vm-post"), and querySelector (singular) always returns just the first element on the whole page that matches, nothing more. that's fine if there's genuinely only one post, but the moment you add a second .vm-post below it, both "view more" buttons end up wired to that same first post, since the code only ever looked up one post to begin with. clicking either button will always expand the first one, no matter which post's button you actually clicked.
the fix is document.querySelectorAll(".vm-post") (plural) combined with .forEach, so the exact same setup runs once for every post on the page instead of only the first:
document.querySelectorAll(".vm-post").forEach(function (post) {
const more = post.querySelector(".vm-more");
const toggleBtn = post.querySelector(".vm-toggle");
toggleBtn.addEventListener("click", function () {
const isOpen = more.style.maxHeight;
if (isOpen) {
more.style.maxHeight = null;
toggleBtn.textContent = "view more ↓";
} else {
more.style.maxHeight = more.scrollHeight + "px";
toggleBtn.textContent = "view less ↑";
}
});
});
document.querySelectorAll(".vm-post"): collects every.vm-poston the page into a list, instead of stopping at the first one.forEach(function (post) { ... }): repeats everything inside the function once per post in that list. each time through,postrefers to that one specific post, not the page as a wholepost.querySelector(".vm-more")andpost.querySelector(".vm-toggle"): calling.querySelector()onpostinstead of ondocumentis the key part, it only searches inside that one post, so it always finds that post's own.vm-moreand.vm-toggle, never a different post's- because the click listener is also created fresh inside the loop, each button ends up with its own separate listener, closed over its own
moreandtoggleBtnvariables, so clicking one post's button can never affect another post
bonus: auto-truncating long posts for a feed
all three options above assume you've already decided where each post gets cut, you write the short preview and the hidden rest as two separate chunks by hand. that's fine for a handful of posts, but gets tedious fast if you're keeping a long running feed of short updates, like a diary or an activity log. this bonus version instead takes one full block of text and lets javascript figure out where to cut it automatically, based on a character limit you set.
live example: a whole feed of posts, all auto-truncated the same way.
<div class="vm-feed">
<div class="vm-feed-post" data-full="lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua, ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."></div>
<div class="vm-feed-post" data-full="duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur, excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."></div>
</div>
class="vm-feed-post": an empty container for each post, no visible text is typed directly inside it, that gets filled in by javascriptdata-full="...": a custom data attribute holding the post's entire text as a plain html attribute value. this is the one full copy of the text that javascript will read from and truncate as needed- writing the full text as an attribute instead of as regular html content also means you can't accidentally include tags like
<b>or<a>inside it, since attribute values are always treated as plain text. if a post needs actual html formatting inside it, this specific bonus method isn't the right fit, one of the first three options above would work better for that post instead
const CHAR_LIMIT = 80;
document.querySelectorAll(".vm-feed-post").forEach(function (post) {
const fullText = post.dataset.full;
if (fullText.length <= CHAR_LIMIT) {
post.textContent = fullText;
return;
}
const shortText = fullText.slice(0, CHAR_LIMIT).trim() + "...";
const preview = document.createElement("span");
preview.className = "vm-preview";
preview.textContent = shortText;
const full = document.createElement("span");
full.className = "vm-full";
full.textContent = fullText;
full.style.display = "none";
const toggleBtn = document.createElement("button");
toggleBtn.type = "button";
toggleBtn.textContent = "view more ↓";
toggleBtn.addEventListener("click", function () {
const isShowingFull = full.style.display !== "none";
preview.style.display = isShowingFull ? "inline" : "none";
full.style.display = isShowingFull ? "none" : "inline";
toggleBtn.textContent = isShowingFull ? "view more ↓" : "view less ↑";
});
post.appendChild(preview);
post.appendChild(full);
post.appendChild(document.createElement("br"));
post.appendChild(toggleBtn);
});
const CHAR_LIMIT = 80;: the only number you need to touch, how many characters of preview to show before truncating. raise it for longer previews, lower it for shorter onesdocument.querySelectorAll(".vm-feed-post").forEach(...): runs the exact same logic for every post in the feed, so this works for a feed of 3 posts or 300 without writing any extra codepost.dataset.full: reads thedata-fullattribute back out as a normal javascript string,datasetautomatically converts adata-full="..."attribute into.dataset.fullif (fullText.length <= CHAR_LIMIT) { ... return; }: an early exit for short posts, if a post is already shorter than the limit, there's nothing to truncate, so it's just displayed in full with no button at all. thisreturnskips the rest of the function for that particular post only, theforEachloop continues on to the next one as normalfullText.slice(0, CHAR_LIMIT): cuts the string down to only its first 80 characters (or however manyCHAR_LIMITis set to)..trim()afterward cleans up if that cut happened to land in the middle of some whitespace, and"..."is appended to signal there's more- two separate spans,
previewandfull, are created and both inserted into the post, butfullstarts withdisplay: none. the button's click handler just flips which of the two is visible, rather than measuring and animating a height like option 3 did, this bonus version deliberately keeps things simpler since it's already doing more work elsewhere document.createElement(...)appearing three separate times: this is building the preview text, the full text, and the button entirely with javascript, rather than typing them into the html by hand. that's the whole point of this method, the html only has to contain the rawdata-fulltext once, everything visitors actually see is generated automatically from it
fullText.slice(0, fullText.lastIndexOf(" ", CHAR_LIMIT)), which rounds the cut back to the nearest whole word.which one should you use?
- use option 1 (
<details>) when you want the absolute simplest setup, don't need an animation, and are fine with the browser's own styling limits - use option 2 (checkbox hack) when you want a smooth animated reveal and full styling control, but would rather not write any javascript at all
- use option 3 (javascript toggle) when post lengths vary a lot and you don't want to guess a max-height, or when you want extras like an
aria-expandedattribute for accessibility - use the bonus auto-truncate version when you're keeping a running feed of many short updates and don't want to manually decide, and manually mark up, where each individual post gets cut
result
four different roads to the same destination: a post that starts short and opens up on click. option 1 needs zero extra code but has the least control, option 2 adds a smooth animation with pure css, option 3 trades a little more javascript for exact, no-guessing height animation, and the bonus version automates the whole truncating process for feeds where writing a preview by hand for every single post just isn't realistic. mix and match freely, plenty of sites use <details> for a faq section and the auto-truncate version for their main update feed, on the same page.