Expand / Collapse

Free

Animated height expand/collapse container using pure JS measurement (no @angular/animations) with lazy content rendering and accessible aria-hidden handling.

</>sv-expand expandcollapseanimation
import { SvExpandComponent } from '@styloviz/layout';

Demo & usage

Basic usage

Usage example — open the gallery for the full interactive demo.

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Custom timing

example.htmlAngular
<sv-expand [(expanded)]="open" [duration]="250" easing="ease-in-out">…</sv-expand>

Full disclosure wiring (aria-controls)

Give the panel a panelId and point your trigger's aria-controls / aria-expanded at it. Pass ariaLabelledby so the panel is exposed as a labelled region.

example.htmlAngular
<button id="more-btn"
        [attr.aria-expanded]="open"
        aria-controls="more-panel"
        (click)="open = !open">Details</button>

<sv-expand [(expanded)]="open"
           panelId="more-panel"
           ariaLabelledby="more-btn">
  <p>Revealed content…</p>
</sv-expand>

When to use

  • Animating arbitrary content open/closed by height — show-more panels, inline detail.
  • You want a smooth transition without pulling in @angular/animations.
  • For truncating long text specifically, use Line Clamp; for grouped sections, Accordion.

Async states

None — this component has nothing to load, so it has no loading, empty or error state to drive.

Inputs

NameTypeDefaultDescription
expandedbooleanfalseControls open/closed state.
durationnumber250Animation duration in ms. Ignored under `prefers-reduced-motion: reduce`, where the panel opens and closes without a height transition.
easingstring'ease-in-out'CSS easing function.
lazybooleantrueOnly attach content after first open. Content is RETAINED after that — a collapse hides it, it never destroys it. ⚠️ Projected components are still instantiated by the host up front; wrap them in your own `@if` / `@defer` to defer creation.
panelIdstring''Optional id for the collapsible panel. Point your trigger's `aria-controls` at this id to complete the ARIA disclosure relationship.
ariaLabelledbystring''Id of the element that labels this panel (usually the trigger button).
ariaLabelstring''Explicit accessible name for the panel (used when `ariaLabelledby` is unset).
returnFocusTostring | HTMLElement | nullnullWhere focus goes when the panel collapses while focus is inside it — an element, or the id of one (normally the trigger). A COLLAPSE MUST NEVER STRAND FOCUS. The content is about to be hidden and made `inert`; a focused control inside it would drop focus to `<body>`, and the next Tab restarts at the top of the page. When this is unset (or cannot take focus) the fallbacks are, in order: the element named by `ariaLabelledby`, any element whose `aria-controls` names `panelId`, and finally the `sv-expand` host itself (made focusable with `tabindex="-1"` for that one move).

Outputs

EventPayload typeDescription
expandedChangebooleanEmitted after the animation completes — once, for the final state of a burst of toggles.

Customization & theming

  • Drive open state with the two-way `[(expanded)]` model.
  • Tune the `duration` and `easing` of the height transition.
  • Defer rendering of heavy content with `lazy`.
  • Wire the ARIA disclosure relationship with `panelId` (for a trigger's `aria-controls`) and `ariaLabelledby` / `ariaLabel` (exposes the panel as a labelled `region`).
  • Variant axes (X-8): `duration` and `easing` are appearance; `expanded`, `lazy` and `returnFocusTo` are behavioural modes; `panelId` / `ariaLabelledby` / `ariaLabel` are semantics. All combine freely. Limits: height (vertical) only; a `duration` of 0 still settles through the fallback timer, one tick later.

Best practices

  • Do: Use `lazy` to defer attaching heavy content until first expand.
  • Do: Keep collapsed content out of the tab order (aria-hidden + inert, handled).
  • Do: Pass `returnFocusTo` (the trigger element or its id) so a collapse that happens while focus is inside the panel returns focus to the control that opened it.
  • Tip: Lazy lifecycle: `lazy` defers INSERTING the content, not creating it — Angular instantiates projected components in the host's view up front. Once opened, content is RETAINED: collapsing ends in `display: none` after the transition and never destroys it, so form and child state survive a re-open. It is destroyed only with `sv-expand` itself. To defer creation, wrap the content in your own `@if` / `@defer`.
  • Tip: Measurement (X-4): the height is measured automatically — `scrollHeight` at the start of the open, and the content box is observed (`svElementSize`) during it, so an image or font that grows the content mid-open re-targets the transition instead of clipping. After the open settles the height is `auto`, so later growth needs nothing. There is no manual `measure()`.
  • Tip: Interrupted transitions: last write wins. Every change of `expanded` cancels the in-flight settle, starts from the height actually reached (a reversal never snaps to 0 or to full), and `expandedChange` fires exactly once — for the final state, when it has settled.
  • Tip: Layout (X-9): not applicable beyond normal flow — the panel is an in-flow wrapper that animates its own height and clips (`overflow: hidden`) only while it animates. Nothing is portalled, no scroll root is created or listened to, no containing block matters, and there are no breakpoints. Absolutely positioned content that pokes out of the panel is clipped during the transition and shows again once it settles.
  • Tip: Platform limits (X-11): `transitionend` is not relied on — a fallback timer (`duration` + 50ms) settles the panel where no transition runs. `inert` is supported by every evergreen browser; on an engine without it the collapsed panel still ends in `display: none` (out of the tab order), only the closing transition window is unprotected. Content growth is observed with `ResizeObserver`; without it (and on the server) only the initial `scrollHeight` is used.
  • Do: Give the panel a `panelId` and wire your trigger's `aria-controls` + `aria-expanded` to it for a complete disclosure pattern.
  • Tip: Reduced motion is automatic: under `prefers-reduced-motion` the height change is instant (read on each toggle), and `expandedChange` and the final state settle exactly as they do with animation.

Accessibility

  • Collapsed content is `aria-hidden` and `inert` — out of the tab order from the moment the collapse starts, not only once it has finished.
  • Focus after collapse (X-10): if focus is inside the panel when it collapses, it moves to `returnFocusTo`, else the element named by `ariaLabelledby`, else a control whose `aria-controls` names `panelId`, else the `sv-expand` host (made focusable with `tabindex="-1"` for that move) — never `<body>`. Focus outside the panel is left alone.
  • Optional `panelId` lets a trigger reference the panel via `aria-controls`; `ariaLabelledby` / `ariaLabel` expose it as a named `role="region"`.
  • Height animates without layout-shift surprises.
  • Honours reduced-motion preferences.

Feedback on Expand / Collapse

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.