Pošlite nám správu

Spojte sa s nami

Telefonné číslo

+421 948 490 415

Edit Template

Angular UI Сhallenge— Animated Collapsible

Tento článok je dostupný len v anglickom jazyku.

Creating reusable and accessible UI components is a cornerstone of modern web development, especially in frameworks like Angular.

I invite you to explore a series of articles where we’ll build various components step by step, tackling challenges and uncovering practical solutions along the way ✨

From simple to complex

In the first article, we’ll start with the simplest component — Collapsible, whose logic boils down to animating the expansion and collapse of content. There are plenty of implementations of this component in plain JS (and, considering modern styling capabilities, even in pure CSS). However, I propose using approaches that are fully supported by major browsers at the time of writing and allow us to seamlessly integrate the implemented logic into Angular’s declarative nature.

Let’s define the requirements

Here’s what the component should be able to do:

  • Expose an [open] input to toggle the content’s expanded or collapsed state
  • Animate the expansion and collapsing
  • Support basic accessibility features, including motion reduction for sensitive users and appropriate ARIA roles for assistive technologies
  • If the content is collapsed, it should be removed from the DOM

Additionally, in line with Angular’s mechanics:


We’ve covered the requirements, let’s move on to writing the base:

TypeScript
				@Component({
  selector: 'app-collapsible',
  host: {
    role: 'region',
    '[class.open]': 'open()',
  },
  template: `
    @if (open()) {
      <div class="collapsible-content">
        @if (template(); as lazy) {
          <ng-container *ngTemplateOutlet="lazy" />
        }
        <ng-content />
      </div>
    }
  `,
  styleUrl: './collapsible.scss',
  imports: [NgTemplateOutlet],
})
export class Collapsible {
  readonly open = input(false);
  readonly template = contentChild<TemplateRef<unknown>>('collapsibleContent');
}
			

Animation

The key feature of the component is the smooth animation. We’ll achieve this using the transition property on grid-template-rows, as animations for grid tracks (including grid-template-columns) are now supported in all major browsers. To enhance the animation’s smoothness, we can also transition the opacity property:

TypeScript
				:host {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition-property: grid-template-rows, opacity;
  transition-timing-function: ease-in-out;
  transition-duration: var(--duration, 250ms);

  &.open {
    grid-template-rows: 1fr;
    opacity: 1;
  }

  @media (prefers-reduced-motion) {
    --duration: 0;
  }
}

.collapsible-content {
  // We need to constrain the size of the child to the size of the grid row 
  // to ensure the height animation works correctly
  overflow: hidden;
}
			

Pitfalls

Even a simple component like this comes with its challenges. As shown in the preview below, the transition works correctly when expanding, but there’s no animation when collapsing:

This occurs because the child element is removed from the DOM before the transition completes, preventing the animation of the grid-template-rows fraction from finishing.

🚀 Let’s fix this!

Signals + RxJS

As you may have noticed in the previous code snippets, I’m using Angular’s relatively new signal-based API. However, there’s nothing stopping us from combining signals with RxJS to create a reactive flow for managing a new contentShown state (⚠️ keep in mind that at the time of writing, the RxJS Interop package is still in developer preview).

In our case, when the state open changes to false, we need to wait for the transitionend event before setting contentShown state. To achieve this, we can write a simple reactive chain by wrapping the input into a stream using toObservable:

TypeScript
				private readonly el = inject(ElementRef).nativeElement;

readonly contentShown = toSignal(
  toObservable(this.open).pipe(
    switchMap(open => {
      if (open) {
        return of(true);
      } else {
        return fromEvent(this.el, 'transitionend').pipe(map(() => false));
      }
    })
  )
);
			

Important notes:

  • transitionend event bubbles, so we need to handle only the events that occur directly on the host element
  • In our example, the transition involves multiple propertiesgrid-template-rows and opacity. However, we are only concerned with the ending of the grid row transition

Let’s use the filter() operator to discard unnecessary emissions when the transition ends:

TypeScript
				fromEvent<TransitionEvent>(this.el, 'transitionend').pipe(
  filter(e => e.target === e.currentTarget && e.propertyName === 'grid-template-rows'),
  map(() => false)
)
			

The only thing left is to update the condition in the @if block in our template to use contentShown state instead of open, which is updated based on the transition handling:

TypeScript
				@Component({
  selector: 'app-collapsible',
  host: {
    role: 'region',
    '[class.open]': 'open()',
  },
  template: `
    @if (contentShown()) {
      <div class="collapsible-content">
        @if (template(); as lazy) {
          <ng-container *ngTemplateOutlet="lazy" />
        }
        <ng-content />
      </div>
    }
  `,
  styleUrl: './collapsible.scss',
  imports: [NgTemplateOutlet],
})
export class Collapsible {
  private readonly el = inject(ElementRef).nativeElement;

  readonly open = input(false);

  readonly contentShown = toSignal(
    toObservable(this.open).pipe(
      switchMap(open => {
        if (open) {
          return of(true);
        } else {
          return fromEvent(this.el, 'transitionend').pipe(
            filter(e => e.target === e.currentTarget && e.propertyName === 'grid-template-rows'),
            map(() => false)
          );
        }
      })
    )
  );

  readonly template = contentChild<TemplateRef<unknown>>('collapsibleContent');
}
			

That’s it! Let’s take a look at the result:

Now, the transition animation works correctly for both expanding and collapsing the content. Moreover, the content is dynamically removed from the DOM when hidden, eliminating unnecessary overhead.

How to use it?

Collapsible is an atomic component that, in most cases, will serve as a building block for more complex components such as an accordion, tree, and others. Nevertheless, we now have a lightweight component that you can easily create yourself, complete with animation, accessibility support (we recommend following Disclosure ARIA design pattern) and the ability to eagerly or lazily initialize expanding content.

Example of usage:

TypeScript
				<button type="button" 
        [attr.aria-expanded]="open()" 
        (click)="open.set(!open())">
  Trigger
</button>

<app-collapsible [open]="open()">
  <ng-template #collapsibleContent>
    Lazy content
  </ng-template>
  <!-- or -->
  Eager content
</app-collapsible>
			

You can explore the final result on StackBlitz:

Stay tuned for the next article, where we’ll tackle more complex components and refine our approach further!

Tento článok si môžete prečitať na našom Medium

O nás

Vitajte na našom blogu! Prinášame inovatívne a efektívne riešenia a delíme sa o naše skúsenosti, aby ste mohli rásť spolu s nami. 

Odporúčané články

Najnovšie články

Zistite viac o našej spoločnosti

Navrhujeme softvérové riešenia, ktoré posunú váš biznis vpred!

CORETEQ Technology s.r.o.

CREATED BY DMITRY B. AND MAKHABBAT B.