Show More
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add utility/show-morePlain-CSS theme — no build step
npx astrocraft-ui add utility/show-more --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add utility/show-more --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add utility/show-more --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add utility/show-more --theme tailwind --bridge lumosLive demo
ShowMore
The clamp is applied by the script, not the markup: with JavaScript off you get all of the text rather than a cut-off paragraph with no way to open it. The button only appears when the text actually overflows, which is why it has to measure — and it measures again when the column narrows. Resize the window and watch it appear and disappear.
The two libraries share a behavior layer and nothing else. They will drift in styling and must not drift in behavior, so four modules are kept byte-identical and a script diffs them; the rest is read as a reference diff rather than copied. That is also why a new anchored overlay extends _anchor.ts rather than the frozen _popover.ts: the freeze is what stops a behavior fix landing in one library and not the other, and it is worth more than the small duplication it costs.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `ShowMore.astro` | `show-more` `show-more-content` `show-more-label-less` `show-more-label-more` `show-more-trigger` | — | `data-expanded` | `[aria-expanded]` `[hidden]` |
Source
What the command copies — 2 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/utility/show-more/ShowMore.astro — headless primitive (see ../../README.md).
// Clamp a block of text to N lines with a real button to open it — a review, a description, a log
// line, a bio.
//
// THE CLAMP IS APPLIED BY THE SCRIPT, NOT BY THE MARKUP, and that is the whole design. A clamp
// rendered server-side is a clamp that survives with JavaScript off, and the button that opens it
// does not: the text is simply cut, permanently, with no way to reach the rest. So this ships
// unclamped, and the script clamps it in the same pass that reveals the button. No JavaScript, no
// clamp, all the content — which is the failure mode you want.
//
// THE BUTTON IS HIDDEN UNTIL IT IS NEEDED, which is why measuring is unavoidable: whether three
// lines of text overflow depends on the font, the container's width and the user's zoom, none of
// which is known at build time. A "Show more" button that opens nothing is the most common bug in
// hand-rolled versions of this. It is re-measured on resize, because narrowing a column adds lines.
//
// The line count is the `lh` unit — one line-height of this element, whatever the theme made that.
// A browser too old for `lh` drops the declaration, nothing is clamped, and the button stays hidden:
// the same no-JavaScript outcome, arrived at differently.
//
// Both labels are rendered and CSS picks one, keyed on the button's own `aria-expanded` — the
// PasswordInput trick (contract rule 4: the state is already in the DOM, and no JavaScript writes
// text or classes). That rule is in structure.css, which is why this file imports it.
//
// The `more` and `less` slots take TEXT or an icon, never a button: the trigger already is one, and
// a <button> inside a <button> is invalid HTML that the parser silently moves OUT of it — leaving a
// trigger with no accessible name and a stray control beside it that does nothing. Style the trigger
// itself through `[data-slot="show-more-trigger"]`.
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
/** Lines to show when collapsed. */
lines?: number;
/** Start open. The button still renders, so it can be closed again. */
expanded?: boolean;
label?: string;
lessLabel?: string;
};
const {
lines = 3,
expanded = false,
label = "Show more",
lessLabel = "Show less",
class: className,
...rest
} = Astro.props;
const contentId = `show-more-${crypto.randomUUID().slice(0, 8)}`;
---
<div
class={className}
data-slot="show-more"
data-lines={lines}
data-expanded={expanded ? "true" : "false"}
{...rest}
>
<div id={contentId} data-slot="show-more-content"><slot /></div>
{/* Hidden until the script has measured an actual overflow — see the header. */}
<button
type="button"
hidden
aria-expanded={expanded ? "true" : "false"}
aria-controls={contentId}
data-slot="show-more-trigger"
>
<span data-slot="show-more-label-more"><slot name="more">{label}</slot></span>
<span data-slot="show-more-label-less"><slot name="less">{lessLabel}</slot></span>
</button>
</div>
<script>
import { onReadyOnce } from "../../_once";
function wire(root: HTMLElement) {
const content = root.querySelector<HTMLElement>('[data-slot="show-more-content"]');
const button = root.querySelector<HTMLElement>('[data-slot="show-more-trigger"]');
if (!content || !button) return;
const lines = Number(root.dataset.lines) || 3;
const clamp = (on: boolean) => {
content.style.overflow = on ? "hidden" : "";
content.style.maxHeight = on ? `${lines}lh` : "";
};
/**
* Decide whether the button is needed, by comparing the text's real height with the clamped one.
* Both heights are read explicitly rather than inferred from `scrollHeight` while clamped: an
* overflow container's reported scroll height varies with the overflow value, and this comparison
* does not care what the theme set.
*/
const measure = () => {
if (root.dataset.expanded === "true") return;
clamp(false);
const full = content.scrollHeight;
clamp(true);
const overflows = full > content.clientHeight + 1; // +1 absorbs sub-pixel rounding
button.hidden = !overflows;
// Nothing to hide: drop the clamp entirely rather than leave a hairline cropping a descender.
if (!overflows) clamp(false);
};
button.addEventListener("click", () => {
const open = root.dataset.expanded !== "true";
root.dataset.expanded = String(open);
button.setAttribute("aria-expanded", String(open));
clamp(!open);
});
measure();
// Re-measure on WIDTH changes only: a narrower column wraps more lines, and text that fitted in
// three at desktop width may need five on a phone. Filtering on width is not an optimisation —
// measuring changes the element's HEIGHT, so an observer that reacted to height would re-enter
// itself on its own writes, which is the classic "ResizeObserver loop" that ends in a console
// full of errors and a flickering button.
let lastWidth = -1;
new ResizeObserver(() => {
const width = content.clientWidth;
if (width === lastWidth) return;
lastWidth = width;
measure();
}).observe(content);
}
onReadyOnce('[data-slot="show-more"]', wire);
</script>
import ShowMore from "./ShowMore.astro";
export { ShowMore };
export default ShowMore;
What you get
The component source, copied into your project by npx astrocraft-ui add utility/show-more — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.