Skip to main content
astrocraft-ui/ components · 101

Show More

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add utility/show-more

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add utility/show-more --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add utility/show-more --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add utility/show-more --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add utility/show-more --theme tailwind --bridge lumos

Live 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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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>
src/components/ui/utility/show-more/index.ts
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.