All Components

Free Etch Component

Read More

Keep long pages tidy without burying what matters. Collapse any Etch block behind a soft fade and an animated Read more button that disappears when content is short. Accessible by default, and free.
Freecopy, paste, done

Documentation

Overview

A small, vanilla collapse/expand component for long-form content. It hides the bottom of long content behind a soft fade and adds a trigger that expands and collapses it. The CSS and JS ship with the component, so there is nothing else to load.

1. Import Component

First, import the component and add your content.

  1. Click Copy JSON above and paste it into any Etch page (Cmd/Ctrl + V on the canvas).
  2. Place your content inside the component’s Content slot. Paragraphs, images, lists, and any other blocks all work.
  3. Adjust the component properties in the sidebar.

2. Core Behaviors

Auto-Hiding Trigger

If the content fits inside the collapsed height, the trigger fades out.

Content Resize Detection

Any change to the content height is detected. This includes late-loading images, font swaps, and window resizes. If the content grows past the collapsed height, the trigger comes back.

Remembers State

Once a section is expanded, it stays expanded across reflows.

No Layout Shift on Load

Content renders at its collapsed height from the first paint, so the page doesn’t jump when the script loads. Instances set to Start Open are skipped.

Opens on Keyboard Focus

If a keyboard user tabs onto a link or button inside collapsed content, the content expands so the focused element is never hidden. Mouse clicks don’t trigger this.

Keeps Your Place

If you collapse a section after scrolling past its top, the page scrolls back to the component so you don’t lose your spot.

Reduced Motion

Users with prefers-reduced-motion get an instant toggle instead of an animation. Expand and collapse events fire right away too.

Accessibility

The trigger gets aria-expanded and aria-controls, and the label swap is announced correctly. Focusing content inside a collapsed section opens it automatically.

3. Component Properties

Each instance has a set of component properties that control its behavior.

PropertyDescriptionAccepted ValueRequired
Collapsed HeightContent taller than this collapses and shows the trigger. Default: 160.Number (px)No
Fade SizeHow far up the bottom fade reaches while collapsed. Sets --mask-amount. Default: 80px.CSS length (px or %)No
Button PositionWhere the trigger sits. Default: Below content.Select: Below content, Over the content (bottom), Over the content (middle)No
Button AlignmentHorizontal alignment of the trigger. Only shown when the trigger sits below the content. Sets --trigger-align. Default: Center.Select: Center, Left, RightNo
Start OpenRenders the content open on first paint. The trigger can still collapse it. Default: Off.ToggleNo
Expand Button TextTrigger label while collapsed. Default: Read more.TextNo
Collapse Button TextTrigger label while expanded. Default: Read less.TextNo
Custom ClassesAdds classes to the .read-more root. Useful for setting CSS variables per instance.CSS classes with the ‘.’ (e.g. .my-class)No

For timing, easing, gaps, and trigger styling, see the Customization page. For scripting, see the JavaScript API page.

Customization

This page covers everything you can change beyond the component properties, without touching the JS. Most setups only need one or two of these.

Available CSS Variables

These CSS variables control the core styling of the component without the need to override specificity. Set them inline, in a class, or globally.

On the .read-more root

VariableDefaultDescription
--transition-duration260msBase speed for the collapse/expand animation. The component scales the actual duration between roughly 70% and 160% of this value depending on how far the content has to travel, so short and long expansions feel like the same gesture. Also drives the trigger fade and label width swap.
--transition-easingcubic-bezier(0.22, 1, 0.36, 1)The easing curve for every transition in the component.
--trigger-aligncenterHow the trigger is aligned under the content. Accepts any align-items value (flex-start, center, flex-end). Ignored in overlay modes.
--trigger-gapvar(--space-l, 1.5rem)The gap between the content and the trigger.
--mask-amount0pxHow far up the bottom fade extends while collapsed. Larger values give a longer, softer fade. Try 80px to 120px for normal text, or 100% to fade the entire collapsed area. The fade animates out as the content expands. The Fade Size prop sets this to 80px by default.
--read-more-clampCollapsed HeightThe collapsed height applied before the script loads. Set automatically by the component from the Collapsed Height prop. You generally don’t need to touch this.

On the .read-more__content

VariableDefaultDescription
--content-max-heightnoneSet internally by the JS during animations. You generally don’t need to touch this.

Example:

<div
  class="read-more"
  style="
    --mask-amount: 80px;
    --trigger-align: flex-start;
    --trigger-gap: 1rem;
    --transition-duration: 600ms;
  "
>
  ...
</div>

Trigger positions

The trigger has three layout modes. Pick one with the Button Position prop.

Below the content (default)

The trigger sits in normal flow underneath the content, aligned by --trigger-align.

Good for: articles, comment threads, anywhere the trigger should feel like part of the page rhythm.

Over the content (bottom)

The trigger floats over the bottom of the collapsed content. The mask fade gives it a soft backdrop so the label stays readable over the text underneath.

When expanded, the trigger drops back into normal flow below the content with the regular --trigger-gap restored.

Good for: image-heavy cards, product descriptions, anywhere you want the trigger to feel like a “see more” call to action over a teaser.

Pair with a larger --mask-amount (try 100px to 150px) to give the trigger breathing room.

Over the content (middle)

Same as overlay, but the trigger sits at the vertical center of the collapsed element instead of pinned to the bottom. Drops back to the bottom when expanded.

Good for: tall hero areas where the trigger should be the visual focal point.

Push --mask-amount higher (try 100%) so the fade extends most of the way up and the centered trigger reads as the focal point.

Styling the trigger

The trigger button has no built-in visual style. It uses your site’s default button styles, so you can style it however you want.

Useful selectors:

.read-more-trigger { /* always-on styles */ }
.read-more-trigger.is-active { /* when the trigger is visible */ }
.read-more-trigger[aria-expanded="true"] { /* when content is expanded */ }
.read-more-trigger:focus-visible { /* keyboard focus */ }

The component also includes a chevron-friendly icon transform. Any svg inside the trigger rotates 180 degrees when expanded, so a down-chevron flips to point up automatically.

Label width animation

When the two labels are different lengths, the trigger animates its width between them. This is on by default and uses the same --transition-duration as everything else. To disable it, set the duration to 0ms on a per-component basis.

<div class="read-more" style="--transition-duration: 0ms">...</div>

State classes

The component adds these classes to the .read-more root, so you can hook styles onto its state.

  • .read-more--ready: added once the script initializes.
  • .expanded: the content is expanded.
  • .read-more--no-toggle: the content fits, so the trigger is hidden.

Without JavaScript

Without JavaScript, content stays at its collapsed height and the trigger stays hidden.

Reduced motion

Users with prefers-reduced-motion: reduce get all transitions removed automatically. The collapse and expand become instant. You don’t need to do anything to support this.

Common recipes

Hide the fade

<div class="read-more" style="--mask-amount: 0px">...</div>

Sharp cutoff with no fade and a left-aligned trigger

<div
  class="read-more"
  style="--mask-amount: 0px; --trigger-align: flex-start;"
>
  ...
</div>

Different threshold on mobile

The Collapsed Height prop sets one value for every screen size. To change it per breakpoint, see the JavaScript API page.

Full-content centered overlay teaser

Set Collapsed Height to 180, Button Position to Over the content (middle), and Fade Size to 100%.

JavaScript API

Most setups don’t need any JS beyond loading the script. The component auto-initializes every .read-more on the page after DOMContentLoaded. Reach for the JavaScript API when you want to toggle a component from your own code, react to expand and collapse, initialize content added after page load, change the default selectors, or tear an instance down cleanly.

Getting an instance

After auto-init, each root element has its instance attached as _readMore:

const rm = document.querySelector(".read-more")._readMore;
rm.expand();

If you’d rather capture instances at init time, ReadMore.init() returns the ones it creates:

const instances = ReadMore.init(".read-more");
const first = instances[0];
if (first) first.toggle();

Or construct one directly against an element you already have:

const root = document.querySelector(".my-collapse");
const instance = new ReadMore(root, { defaultMaxHeight: 200 });

Instance methods

MethodDescriptionNotes
expand()Expand the content.No-op if already expanded or if the content is short enough that no toggle is needed. Dispatches readMore:will-expand / readMore:will-change immediately, then readMore:expand / readMore:change after the animation completes.
collapse()Collapse the content.No-op if already collapsed or if the content is short enough that no toggle is needed. Dispatches readMore:will-collapse / readMore:will-change immediately, then readMore:collapse / readMore:change after the animation completes. Accepts { fromUser: true } to scroll the component back into view if its top is off-screen.
toggle()Flip between expanded and collapsed.No-op if no toggle is needed. Accepts { fromUser: true }, which is what the trigger click passes, to turn on the keep-your-place scroll. Dispatches the matching pair of events for whichever direction it goes.
updateCollapseState()Re-check whether the content needs collapsing and show or hide the trigger accordingly.Called automatically by the internal ResizeObserver. Call manually after swapping content if the height isn’t being picked up.
setMaxHeight(value)Set the inline --content-max-height CSS variable on the content.Used internally by the animation. Pass null to clear it. Rarely needed in user code.
destroy()Remove all listeners and observers, cancel pending timers and animation frames, and restore the original markup. That means the label, inline styles, state classes and any generated ID. Clears the _readMore reference from the root.Call before removing the root element from the DOM for a clean teardown.

Instance properties

PropertyTypeDescription
rootElementThe outer .read-more element.
contentElementThe content element matched by contentSelector.
buttonElementThe trigger button matched by triggerSelector.
isExpandedbooleanGetter that returns true when the root has the expanded class.
userExpandedbooleanWhether the user has manually expanded this instance. Persists across reflows.
maxHeightnumberGetter that reads data-max-height from the root and falls back to defaultMaxHeight. Re-read on every collapse check, so updating the attribute updates the threshold live.
labels{ more, less }References to the two label spans inside the trigger.
labelWidths{ more, less }The measured pixel widths of each label, used to animate the trigger width. Re-measured when web fonts load, when the type size changes, or when a hidden instance becomes visible.

Constructor options

Passed as the second argument to new ReadMore(root, options).

OptionDescriptionTypeDefaultHTML equivalent
contentSelectorSelector for the content element inside the root.string".read-more__content"(none)
triggerSelectorSelector for the trigger button inside the root.string".read-more-trigger"(none)
triggerTextSelectorSelector for the element that holds the label text. Gets replaced with the animated label markup on init.string".read-more-trigger > span"(none)
defaultMaxHeightFallback collapsed height (in pixels) when the root has no data-max-height attribute.number100data-max-height (per-instance override)
stabilizeMsHow long the content height has to stay stable before the trigger hides on short content. Raise this if your page has late-loading images or fonts that cause height to bounce.number300(none)

Events

All events are dispatched on the root .read-more element and bubble, so you can listen on the root itself or on any ancestor. Every event includes { isExpanded } in its detail.

EventWhen it firesdetail.isExpanded
readMore:will-expandThe moment expand intent flips the state, before the animation starts.true
readMore:will-collapseThe moment collapse intent flips the state, before the animation starts.false
readMore:will-changeSame timing as the matching will-expand / will-collapse. Use this when you don’t care which direction.true or false
readMore:expandAfter the expand animation completes.true
readMore:collapseAfter the collapse animation completes.false
readMore:changeSame timing as the matching expand / collapse.true or false
const rm = document.querySelector(".read-more");

rm.addEventListener("readMore:expand", () => {
  // analytics, scrollIntoView, focus management, etc.
});

rm.addEventListener("readMore:change", (e) => {
  console.log("now", e.detail.isExpanded ? "expanded" : "collapsed");
});

Static methods

MethodDescription
ReadMore.init(rootSelector)Find every matching root on the page and return an array of instances. Roots that are already initialized return their existing instance instead of being set up twice, so it’s safe to call again after adding content. Defaults to .read-more. This is what runs automatically on DOMContentLoaded.

Examples

Reach in from another script and react to expand

const rm = document.querySelector(".read-more");

rm.addEventListener("readMore:expand", () => {
  // analytics, scrollIntoView, focus management, etc.
});

Drive a component from your own button

const bio = document.querySelector("#bio")._readMore;

document.querySelector("#open-bio").addEventListener("click", () => {
  bio.expand();
});

document.querySelector("#close-bio").addEventListener("click", () => {
  bio.collapse();
});

Capture instances at init

const instances = ReadMore.init(".read-more");

document.querySelector("#expand-all").addEventListener("click", () => {
  instances.forEach((rm) => rm.expand());
});

Initialize content added after page load

// after dropping new .read-more markup into the DOM
const fresh = ReadMore.init(".new-section .read-more");

Custom selectors for non-default markup

const root = document.querySelector(".product-description");
new ReadMore(root, {
  contentSelector: ".product-description__body",
  triggerSelector: ".product-description__toggle",
  triggerTextSelector: ".product-description__toggle .label",
  defaultMaxHeight: 240,
});

root.addEventListener("readMore:expand", () => {
  console.log("product description expanded");
});

Check current state

const rm = document.querySelector(".read-more")._readMore;

if (rm.isExpanded) {
  rm.collapse();
} else {
  rm.expand();
}

Re-init after swapping the inner content

// destroy first to clean up listeners and timers
instance.destroy();

// swap in your new content
content.innerHTML = newMarkup;

// build a fresh instance against the same root
const fresh = new ReadMore(root);

Adjust the threshold on resize

// data-max-height is read live, so updating the attribute updates the threshold
window.addEventListener("resize", () => {
  const desktop = window.innerWidth >= 768;
  document.querySelectorAll(".read-more").forEach((el) => {
    el.setAttribute("data-max-height", desktop ? "240" : "120");
  });
});

Force a re-check after content changes

// most height changes are picked up automatically by ResizeObserver.
// for the rare case it isn't, nudge it manually.
instance.updateCollapseState();

Clean teardown before removing from the DOM

instance.destroy();
root.remove();

Read More: live preview