AvesraAvesrabeta

Modal

Dialog overlay for focused user interactions and important content.

Import

import {
  AvModalImports,
  AvModalService,
} from '@avesra/angular';

Usage

import { Component } from '@angular/core';
import {
  AvButtonComponent,
  AvModalImports,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

@Component({
  selector: 'app-modal-basic-demo',
  imports: [
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<av-modal>
  <button av-button variant="secondary" av-modal-trigger>Open Modal</button>
  <ng-template avModalContent>
    <div av-modal-dialog class="sm:max-w-[360px]">
      <av-modal-close-trigger />
      <div av-modal-header>
        <div av-modal-icon class="bg-default text-foreground">
          <app-icon icon="solar:rocket-linear" size="20" />
        </div>
        <h2 av-modal-heading>Welcome to Avesra</h2>
      </div>
      <div av-modal-body>
        <p>
          A beautiful, fast, and modern Angular UI library for building accessible and
          customizable web applications with ease.
        </p>
      </div>
      <div av-modal-footer>
        <button av-button class="w-full" av-modal-close>Continue</button>
      </div>
    </div>
  </ng-template>
</av-modal>`,
})
export class ModalBasicDemo {}

Anatomy

Declare the trigger and an ng-template avModalContent inside av-modal. The overlay content is rendered into a CDK overlay only while the modal is open. Optional pieces include av-modal-close-trigger and av-modal-icon.

<av-modal>
  <button av-button av-modal-trigger>Open Modal</button>
  <ng-template avModalContent>
    <div av-modal-dialog>
      <!-- Optional: Close button -->
      <av-modal-close-trigger />
      <div av-modal-header>
        <!-- Optional: Icon -->
        <div av-modal-icon></div>
        <h2 av-modal-heading></h2>
      </div>
      <div av-modal-body></div>
      <div av-modal-footer></div>
    </div>
  </ng-template>
</av-modal>

Placement

Set placement on av-modal — auto, top, center, or bottom.

import { Component } from '@angular/core';
import { TitleCasePipe } from '@angular/common';
import {
  AvButtonComponent,
  AvModalImports,
  type AvModalPlacement,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

@Component({
  selector: 'app-modal-placements-demo',
  imports: [
    TitleCasePipe,
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<div class="flex flex-wrap gap-4">
  @for (placement of placements; track placement) {
    <av-modal [placement]="placement">
      <button av-button variant="secondary" av-modal-trigger>
        {{ placement | titlecase }}
      </button>
      <ng-template avModalContent>
        <div av-modal-dialog class="sm:max-w-[360px]">
          <av-modal-close-trigger />
          <div av-modal-header>
            <div av-modal-icon class="bg-default text-foreground">
              <app-icon icon="solar:rocket-linear" size="20" />
            </div>
            <h2 av-modal-heading>Placement: {{ placement | titlecase }}</h2>
          </div>
          <div av-modal-body>
            <p>
              This modal uses the <code>{{ placement }}</code> placement option. Try different
              placements to see how the modal positions itself on the screen.
            </p>
          </div>
          <div av-modal-footer>
            <button av-button class="w-full" av-modal-close>Continue</button>
          </div>
        </div>
      </ng-template>
    </av-modal>
  }
</div>`,
})
export class ModalPlacementsDemo {
  readonly placements: readonly AvModalPlacement[] = ['auto', 'top', 'center', 'bottom'];
}

Backdrop Variants

Choose a backdrop style with backdrop on av-modal: opaque (default), blur, or transparent.

import { Component } from '@angular/core';
import { TitleCasePipe } from '@angular/common';
import {
  AvButtonComponent,
  AvModalImports,
  type AvModalBackdropVariant,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

@Component({
  selector: 'app-modal-backdrop-variants-demo',
  imports: [
    TitleCasePipe,
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<div class="flex flex-wrap gap-4">
  @for (variant of backdropVariants; track variant) {
    <av-modal [backdrop]="variant">
      <button av-button variant="secondary" av-modal-trigger>
        {{ variant | titlecase }}
      </button>
      <ng-template avModalContent>
        <div av-modal-dialog class="sm:max-w-[360px]">
          <av-modal-close-trigger />
          <div av-modal-header>
            <div av-modal-icon class="bg-default text-foreground">
              <app-icon icon="solar:rocket-linear" size="20" />
            </div>
            <h2 av-modal-heading>Backdrop: {{ variant | titlecase }}</h2>
          </div>
          <div av-modal-body>
            <p>
              This modal uses the <code>{{ variant }}</code> backdrop variant. Compare the
              different visual effects: opaque provides full opacity, blur adds a backdrop
              filter, and transparent removes the background.
            </p>
          </div>
          <div av-modal-footer>
            <button av-button class="w-full" av-modal-close>Continue</button>
          </div>
        </div>
      </ng-template>
    </av-modal>
  }
</div>`,
})
export class ModalBackdropVariantsDemo {
  readonly backdropVariants: readonly AvModalBackdropVariant[] = ['opaque', 'blur', 'transparent'];
}

Sizes

Pass size to av-modal — xs, sm, md, lg, cover, or full. Individual dialogs can override it with size on div[av-modal-dialog].

import { Component } from '@angular/core';
import { TitleCasePipe } from '@angular/common';
import {
  AvButtonComponent,
  AvModalImports,
  type AvModalSize,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

@Component({
  selector: 'app-modal-sizes-demo',
  imports: [
    TitleCasePipe,
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<div class="flex flex-wrap gap-4">
  @for (size of sizes; track size) {
    <av-modal [size]="size">
      <button av-button variant="secondary" av-modal-trigger>
        {{ size | titlecase }}
      </button>
      <ng-template avModalContent>
        <div av-modal-dialog>
          <av-modal-close-trigger />
          <div av-modal-header>
            <div av-modal-icon class="bg-default text-foreground">
              <app-icon icon="solar:rocket-linear" size="20" />
            </div>
            <h2 av-modal-heading>Size: {{ size | titlecase }}</h2>
          </div>
          <div av-modal-body>
            <p>{{ sizeDescription(size) }}</p>
          </div>
          <div av-modal-footer>
            <button av-button variant="secondary" av-modal-close>Cancel</button>
            <button av-button av-modal-close>Confirm</button>
          </div>
        </div>
      </ng-template>
    </av-modal>
  }
</div>`,
})
export class ModalSizesDemo {
  readonly sizes: readonly AvModalSize[] = ['xs', 'sm', 'md', 'lg', 'cover', 'full'];

  sizeDescription(size: AvModalSize): string {
    switch (size) {
      case 'cover':
        return 'This modal uses the cover size variant. It spans the full screen with margins, keeping rounded corners and standard padding.';
      case 'full':
        return 'This modal uses the full size variant. It occupies the entire viewport without margins, rounded corners, or shadows.';
      default:
        return `This modal uses the ${size} size variant. On mobile, sizes adapt to near full-width; on desktop each size has a different max-width.`;
    }
  }
}

Custom Backdrop

Combine the backdrop variant with utility classes passed through backdrop-class for gradients and other overlays.

import { Component } from '@angular/core';
import {
  AvButtonComponent,
  AvModalImports,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

@Component({
  selector: 'app-modal-custom-backdrop-demo',
  imports: [
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<av-modal
  backdrop="blur"
  backdrop-class="bg-linear-to-t from-black/80 via-black/40 to-transparent dark:from-zinc-800/80 dark:via-zinc-800/40"
>
  <button av-button variant="secondary" av-modal-trigger>Custom Backdrop</button>
  <ng-template avModalContent>
    <div av-modal-dialog class="sm:max-w-[360px]">
      <av-modal-close-trigger />
      <div av-modal-header class="items-center text-center">
        <div av-modal-icon class="bg-accent-soft text-accent-soft-foreground">
          <app-icon icon="solar:stars-linear" size="20" />
        </div>
        <h2 av-modal-heading>Premium Backdrop</h2>
      </div>
      <div av-modal-body>
        <p>
          This backdrop features a gradient that transitions from a dark color at the bottom to
          transparency at the top, combined with the blur variant. Utility classes passed through
          <code>backdrop-class</code> layer over the variant styles.
        </p>
      </div>
      <div av-modal-footer class="flex-col-reverse">
        <button av-button class="w-full" av-modal-close>Amazing!</button>
        <button av-button class="w-full" variant="secondary" av-modal-close>Close</button>
      </div>
    </div>
  </ng-template>
</av-modal>`,
})
export class ModalCustomBackdropDemo {}

Dismiss Behavior

Set [dismissable]="false" to prevent backdrop dismissal, and keyboard-dismiss-disabled to disable Escape. Both live on av-modal.

dismissable

Controls whether the modal can be dismissed by clicking the overlay backdrop. Defaults to true. Set to false to require an explicit close action.

keyboard-dismiss-disabled

When set, the Escape key will not close the modal. Users must use an explicit close action or the backdrop (when dismissable).

import { Component } from '@angular/core';
import {
  AvButtonComponent,
  AvModalImports,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

@Component({
  selector: 'app-modal-dismiss-behavior-demo',
  imports: [
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<div class="flex max-w-sm flex-col gap-6">
  <div class="flex flex-col gap-2">
    <h3 class="text-lg font-semibold">dismissable</h3>
    <p class="text-sm text-muted">
      Controls whether the modal can be dismissed by clicking the overlay backdrop. Defaults to
      <code>true</code>. Set to <code>false</code> to require an explicit close action.
    </p>
    <av-modal [dismissable]="false">
      <button av-button variant="secondary" av-modal-trigger>Open Modal</button>
      <ng-template avModalContent>
        <div av-modal-dialog class="sm:max-w-[360px]">
          <av-modal-close-trigger />
          <div av-modal-header>
            <div av-modal-icon class="bg-default text-foreground">
              <app-icon icon="solar:info-circle-linear" size="20" />
            </div>
            <h2 av-modal-heading>dismissable = false</h2>
            <p class="text-sm leading-5 text-muted">Clicking the backdrop won't close this modal</p>
          </div>
          <div av-modal-body>
            <p>
              Try clicking outside this modal on the overlay — it won't close. Use the close
              button or press Escape to dismiss it.
            </p>
          </div>
          <div av-modal-footer>
            <button av-button class="w-full" av-modal-close>Close</button>
          </div>
        </div>
      </ng-template>
    </av-modal>
  </div>

  <div class="flex flex-col gap-2">
    <h3 class="text-lg font-semibold">keyboard-dismiss-disabled</h3>
    <p class="text-sm text-muted">
      When set, the Escape key will not close the modal. Users must use an explicit close action or
      the backdrop (when dismissable).
    </p>
    <av-modal keyboard-dismiss-disabled>
      <button av-button variant="secondary" av-modal-trigger>Open Modal</button>
      <ng-template avModalContent>
        <div av-modal-dialog class="sm:max-w-[360px]">
          <av-modal-close-trigger />
          <div av-modal-header>
            <div av-modal-icon class="bg-default text-foreground">
              <app-icon icon="solar:info-circle-linear" size="20" />
            </div>
            <h2 av-modal-heading>keyboard-dismiss-disabled</h2>
            <p class="text-sm leading-5 text-muted">Escape key is disabled</p>
          </div>
          <div av-modal-body>
            <p>
              Press Escape — nothing happens. Use the close button or click the overlay backdrop
              to dismiss this modal.
            </p>
          </div>
          <div av-modal-footer>
            <button av-button class="w-full" av-modal-close>Close</button>
          </div>
        </div>
      </ng-template>
    </av-modal>
  </div>
</div>`,
})
export class ModalDismissBehaviorDemo {}

Close Methods

Close with av-modal-close on buttons, or set [(open)] to false when you need custom logic first.

Using av-modal-close

The simplest way to close a modal. Add av-modal-close to any interactive element inside the overlay. When clicked, it closes the modal.

Using [(open)]

Drive close from your component by setting open to false. Useful when you need validation or other logic before dismissing.

import { Component, signal } from '@angular/core';
import {
  AvButtonComponent,
  AvModalImports,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

@Component({
  selector: 'app-modal-close-methods-demo',
  imports: [
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<div class="flex max-w-2xl flex-col gap-8">
  <div class="flex flex-col gap-2">
    <h3 class="text-lg font-semibold">Using av-modal-close</h3>
    <p class="text-sm text-muted">
      The simplest way to close a modal. Add <code>av-modal-close</code> to any interactive element
      inside the overlay. When clicked, it closes the modal.
    </p>
    <av-modal>
      <button av-button variant="secondary" av-modal-trigger>Open Modal</button>
      <ng-template avModalContent>
        <div av-modal-dialog class="sm:max-w-[360px]">
          <div av-modal-header>
            <div av-modal-icon class="bg-accent-soft text-accent-soft-foreground">
              <app-icon icon="solar:info-circle-linear" size="20" />
            </div>
            <h2 av-modal-heading>Using av-modal-close</h2>
          </div>
          <div av-modal-body>
            <p>
              Click either button below — both have <code>av-modal-close</code> and will close
              the modal automatically.
            </p>
          </div>
          <div av-modal-footer>
            <button av-button variant="secondary" av-modal-close>Cancel</button>
            <button av-button av-modal-close>Confirm</button>
          </div>
        </div>
      </ng-template>
    </av-modal>
  </div>

  <div class="flex flex-col gap-2">
    <h3 class="text-lg font-semibold">Using [(open)]</h3>
    <p class="text-sm text-muted">
      Drive close from your component by setting <code>open</code> to <code>false</code>. Useful
      when you need validation or other logic before dismissing.
    </p>
    <av-modal [(open)]="programmaticOpen">
      <button av-button variant="secondary" av-modal-trigger>Open Modal</button>
      <ng-template avModalContent>
        <div av-modal-dialog class="sm:max-w-[360px]">
          <div av-modal-header>
            <div av-modal-icon class="bg-success-soft text-success-soft-foreground">
              <app-icon icon="solar:check-circle-linear" size="20" />
            </div>
            <h2 av-modal-heading>Using [(open)]</h2>
          </div>
          <div av-modal-body>
            <p>
              The buttons below call <code>closeProgrammatic()</code>, which sets
              <code>open</code> to <code>false</code> after optional custom logic.
            </p>
          </div>
          <div av-modal-footer>
            <button av-button variant="secondary" (click)="closeProgrammatic()">Cancel</button>
            <button av-button (click)="closeProgrammatic()">Confirm</button>
          </div>
        </div>
      </ng-template>
    </av-modal>
  </div>
</div>`,
})
export class ModalCloseMethodsDemo {
  readonly programmaticOpen = signal(false);

  closeProgrammatic(): void {
    this.programmaticOpen.set(false);
  }
}

Programmatic

Use AvModalService.open() to mount any component as a modal without declaring overlay markup. Pass data through the config, read it with AV_MODAL_DATA, and receive the close result from afterClosed().

Open any component as a modal with AvModalService.open() — no overlay markup in your template.

import { Component, inject, signal } from '@angular/core';
import {
  AV_MODAL_DATA,
  AvButtonComponent,
  AvModalImports,
  AvModalService,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

interface WorkspacePanelData {
  workspace: string;
  members: number;
}

@Component({
  selector: 'app-modal-service-panel',
  imports: [
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<div av-modal-dialog>
      <av-modal-close-trigger />
      <div av-modal-header>
        <div av-modal-icon class="bg-accent-soft text-accent-soft-foreground">
          <app-icon icon="solar:users-group-rounded-linear" size="20" />
        </div>
        <h2 av-modal-heading>{{ data.workspace }}</h2>
      </div>
      <div av-modal-body>
        <p>
          This panel was opened with <code>AvModalService.open()</code>. It receives
          <code>{{ data.members }}</code> members through the <code>data</code> config and injects
          it with <code>AV_MODAL_DATA</code>.
        </p>
      </div>
      <div av-modal-footer>
        <button av-button variant="secondary" [av-modal-close]="'cancelled'">Cancel</button>
        <button av-button [av-modal-close]="'invited'">Invite Members</button>
      </div>
    </div>`,
})
export class ModalServicePanel {
  readonly data = inject(AV_MODAL_DATA) as WorkspacePanelData;
}

@Component({
  selector: 'app-modal-service-demo',
  imports: [AvButtonComponent],
  template: `<div class="flex max-w-md flex-col gap-4">
      <p class="text-sm text-muted">
        Open any component as a modal with <code>AvModalService.open()</code> — no overlay markup
        in your template.
      </p>
      <button av-button variant="secondary" (click)="openWorkspacePanel()">Open Workspace Panel</button>
      @if (lastResult() !== null) {
        <p class="text-sm text-muted">
          Last result:
          <code class="font-mono text-foreground">{{ lastResult() }}</code>
        </p>
      }
    </div>`,
})
export class ModalServiceDemo {
  private readonly modal = inject(AvModalService);

  readonly lastResult = signal<string | null>(null);

  openWorkspacePanel(): void {
    this.modal
      .open<ModalServicePanel, WorkspacePanelData, string>(ModalServicePanel, {
        data: { workspace: 'Avesra Design Team', members: 12 },
        size: 'sm',
      })
      .afterClosed()
      .subscribe((result) => {
        this.lastResult.set(result ?? 'dismissed');
      });
  }
}

Custom Animations

Override enter/exit motion with Tailwind animation utilities passed through backdrop-class and container-class using data-[entering] / data-[exiting] selectors.

import { Component } from '@angular/core';
import {
  AvButtonComponent,
  AvModalImports,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

const kinematicBackdrop = [
  'data-[entering]:duration-400',
  'data-[entering]:ease-[cubic-bezier(0.16,1,0.3,1)]',
  'data-[exiting]:duration-200',
  'data-[exiting]:ease-[cubic-bezier(0.7,0,0.84,0)]',
].join(' ');

const kinematicContainer = [
  'data-[entering]:animate-in',
  'data-[entering]:fade-in-0',
  'data-[entering]:zoom-in-95',
  'data-[entering]:duration-400',
  'data-[entering]:ease-[cubic-bezier(0.16,1,0.3,1)]',
  'data-[exiting]:animate-out',
  'data-[exiting]:fade-out-0',
  'data-[exiting]:zoom-out-95',
  'data-[exiting]:duration-200',
  'data-[exiting]:ease-[cubic-bezier(0.7,0,0.84,0)]',
].join(' ');

const fluidBackdrop = [
  'data-[entering]:duration-500',
  'data-[entering]:ease-[cubic-bezier(0.25,1,0.5,1)]',
  'data-[exiting]:duration-200',
  'data-[exiting]:ease-[cubic-bezier(0.5,0,0.75,0)]',
].join(' ');

const fluidContainer = [
  'data-[entering]:animate-in',
  'data-[entering]:fade-in-0',
  'data-[entering]:slide-in-from-bottom-4',
  'data-[entering]:duration-500',
  'data-[entering]:ease-[cubic-bezier(0.25,1,0.5,1)]',
  'data-[exiting]:animate-out',
  'data-[exiting]:fade-out-0',
  'data-[exiting]:slide-out-to-bottom-2',
  'data-[exiting]:duration-200',
  'data-[exiting]:ease-[cubic-bezier(0.5,0,0.75,0)]',
].join(' ');

@Component({
  selector: 'app-modal-custom-animations-demo',
  imports: [
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<div class="flex flex-wrap gap-4">
  <av-modal [backdrop-class]="kinematicBackdrop" [container-class]="kinematicContainer">
    <button av-button variant="secondary" av-modal-trigger>Kinematic Scale</button>
    <ng-template avModalContent>
      <div av-modal-dialog class="sm:max-w-[360px]">
        <av-modal-close-trigger />
        <div av-modal-header>
          <div av-modal-icon class="bg-default text-foreground">
            <app-icon icon="solar:star-fall-linear" size="20" />
          </div>
          <h2 av-modal-heading>Kinematic Scale Animation</h2>
        </div>
        <div av-modal-body>
          <p class="mt-1">
            Physics-based elastic scaling. Simulates a high-damping spring system with fast
            transient response and prolonged settling time. Ideal for Modals and Popovers.
          </p>
        </div>
        <div av-modal-footer>
          <button av-button variant="tertiary" av-modal-close>Close</button>
          <button av-button av-modal-close>Try Again</button>
        </div>
      </div>
    </ng-template>
  </av-modal>

  <av-modal [backdrop-class]="fluidBackdrop" [container-class]="fluidContainer">
    <button av-button variant="secondary" av-modal-trigger>Fluid Slide</button>
    <ng-template avModalContent>
      <div av-modal-dialog class="sm:max-w-[360px]">
        <av-modal-close-trigger />
        <div av-modal-header>
          <div av-modal-icon class="bg-default text-foreground">
            <app-icon icon="solar:upload-linear" size="20" />
          </div>
          <h2 av-modal-heading>Fluid Slide Animation</h2>
        </div>
        <div av-modal-body>
          <p class="mt-1">
            Simulates movement through a medium with fluid resistance. Eliminates mechanical
            linearity for a natural, grounded feel. Perfect for Bottom Sheets or Toasts.
          </p>
        </div>
        <div av-modal-footer>
          <button av-button variant="tertiary" av-modal-close>Close</button>
          <button av-button av-modal-close>Try Again</button>
        </div>
      </div>
    </ng-template>
  </av-modal>
</div>`,
})
export class ModalCustomAnimationsDemo {
  readonly kinematicBackdrop = kinematicBackdrop;
  readonly kinematicContainer = kinematicContainer;
  readonly fluidBackdrop = fluidBackdrop;
  readonly fluidContainer = fluidContainer;
}

Scroll Behavior

Use scroll="inside" (default) on av-modal to scroll only the body, or scroll="outside" to scroll the entire dialog within the viewport.

import { Component, signal } from '@angular/core';
import { TitleCasePipe } from '@angular/common';
import {
  AvButtonComponent,
  AvModalImports,
  type AvModalScroll,
} from '@avesra/angular';

@Component({
  selector: 'app-modal-scroll-behavior-demo',
  imports: [
    AvModalImports,
    AvButtonComponent,
    TitleCasePipe,
  ],
  template: `<div class="flex flex-col gap-4">
  <div class="flex flex-wrap gap-2">
    <button
      av-button
      size="sm"
      [variant]="scrollMode() === 'inside' ? 'primary' : 'secondary'"
      (click)="setScrollMode('inside')"
    >
      Inside
    </button>
    <button
      av-button
      size="sm"
      [variant]="scrollMode() === 'outside' ? 'primary' : 'secondary'"
      (click)="setScrollMode('outside')"
    >
      Outside
    </button>
  </div>

  <av-modal [scroll]="scrollMode()">
    <button av-button variant="secondary" av-modal-trigger>
      Open Modal ({{ scrollMode() | titlecase }})
    </button>
    <ng-template avModalContent>
      <div av-modal-dialog class="sm:max-w-[360px]">
        <av-modal-close-trigger />
        <div av-modal-header>
          <h2 av-modal-heading>Scroll: {{ scrollMode() | titlecase }}</h2>
          <p class="text-sm leading-5 text-muted">
            Compare scroll behaviors — inside keeps content scrollable within the modal, outside
            allows scrolling the entire dialog in the viewport.
          </p>
        </div>
        <div av-modal-body>
          @for (paragraph of scrollParagraphs; track paragraph) {
            <p class="mb-3">
              Paragraph {{ paragraph }}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
              Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet hendrerit
              risus, sed porttitor quam.
            </p>
          }
        </div>
        <div av-modal-footer>
          <button av-button variant="secondary" av-modal-close>Cancel</button>
          <button av-button av-modal-close>Confirm</button>
        </div>
      </div>
    </ng-template>
  </av-modal>
</div>`,
})
export class ModalScrollBehaviorDemo {
  readonly scrollMode = signal<AvModalScroll>('inside');
  readonly scrollParagraphs = Array.from({ length: 30 }, (_, index) => index + 1);

  setScrollMode(mode: AvModalScroll): void {
    this.scrollMode.set(mode);
  }
}

Controlled State

Bind [(open)] on av-modal to drive visibility from outside the overlay tree — open, close, and toggle programmatically.

Control the modal with a signal and [(open)] on av-modal.

Status: closed

import { Component, signal } from '@angular/core';
import {
  AvButtonComponent,
  AvModalImports,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

@Component({
  selector: 'app-modal-controlled-demo',
  imports: [
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<div class="flex max-w-md flex-col gap-3">
  <p class="text-sm text-muted">
    Control the modal with a signal and <code>[(open)]</code> on <code>av-modal</code>.
  </p>
  <div class="flex flex-col items-start gap-3 rounded-2xl bg-surface p-4 shadow-sm">
    <p class="text-xs text-muted">
      Status:
      <span class="font-mono font-medium text-foreground">
        {{ open() ? 'open' : 'closed' }}
      </span>
    </p>
    <div class="flex gap-2">
      <button av-button size="sm" variant="secondary" (click)="openModal()">Open Modal</button>
      <button av-button size="sm" variant="tertiary" (click)="toggle()">Toggle</button>
    </div>
  </div>

  <av-modal [(open)]="open">
    <ng-template avModalContent>
      <div av-modal-dialog class="sm:max-w-[360px]">
        <av-modal-close-trigger />
        <div av-modal-header>
          <div av-modal-icon class="bg-accent-soft text-accent-soft-foreground">
            <app-icon icon="solar:check-circle-linear" size="20" />
          </div>
          <h2 av-modal-heading>Controlled with [(open)]</h2>
        </div>
        <div av-modal-body>
          <p>
            This modal is controlled by a signal. Bind <code>[(open)]</code> to manage visibility
            from outside the overlay tree.
          </p>
        </div>
        <div av-modal-footer>
          <button av-button variant="secondary" av-modal-close>Cancel</button>
          <button av-button av-modal-close>Confirm</button>
        </div>
      </div>
    </ng-template>
  </av-modal>
</div>`,
})
export class ModalControlledDemo {
  readonly open = signal(false);

  openModal(): void {
    this.open.set(true);
  }

  toggle(): void {
    this.open.update((value) => !value);
  }
}

With Form

Place form fields inside av-modal-body and wire submit actions in av-modal-footer. Focus remains trapped while the modal is open.

import { Component } from '@angular/core';
import {
  AvButtonComponent,
  AvLabelComponent,
  AvModalImports,
  AvSurfaceComponent,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

@Component({
  selector: 'app-modal-with-form-demo',
  imports: [
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
    AvLabelComponent,
    AvSurfaceComponent,
  ],
  template: `<av-modal placement="auto">
  <button av-button variant="secondary" av-modal-trigger>Open Contact Form</button>
  <ng-template avModalContent>
    <div av-modal-dialog class="sm:max-w-md">
      <av-modal-close-trigger />
      <div av-modal-header>
        <div av-modal-icon class="bg-accent-soft text-accent-soft-foreground">
          <app-icon icon="solar:letter-linear" size="20" />
        </div>
        <h2 av-modal-heading>Contact Us</h2>
        <p class="mt-1.5 text-sm leading-5 text-muted">
          Fill out the form below and we'll get back to you. The modal adapts when the keyboard
          appears on mobile.
        </p>
      </div>
      <div av-modal-body class="p-6">
        <div av-surface variant="default" class="rounded-2xl p-4">
          <form class="flex flex-col gap-4" (submit)="$event.preventDefault()">
            <div class="flex w-full flex-col gap-1.5">
              <label av-label for="modal-contact-name">Name</label>
              <input
                id="modal-contact-name"
                class="h-10 w-full rounded-xl border border-border bg-transparent px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-focus"
                placeholder="Enter your name"
              />
            </div>
            <div class="flex w-full flex-col gap-1.5">
              <label av-label for="modal-contact-email">Email</label>
              <input
                id="modal-contact-email"
                type="email"
                class="h-10 w-full rounded-xl border border-border bg-transparent px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-focus"
                placeholder="Enter your email"
              />
            </div>
            <div class="flex w-full flex-col gap-1.5">
              <label av-label for="modal-contact-phone">Phone</label>
              <input
                id="modal-contact-phone"
                type="tel"
                class="h-10 w-full rounded-xl border border-border bg-transparent px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-focus"
                placeholder="Enter your phone number"
              />
            </div>
            <div class="flex w-full flex-col gap-1.5">
              <label av-label for="modal-contact-company">Company</label>
              <input
                id="modal-contact-company"
                class="h-10 w-full rounded-xl border border-border bg-transparent px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-focus"
                placeholder="Enter your company name"
              />
            </div>
            <div class="flex w-full flex-col gap-1.5">
              <label av-label for="modal-contact-message">Message</label>
              <input
                id="modal-contact-message"
                class="h-10 w-full rounded-xl border border-border bg-transparent px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-focus"
                placeholder="Enter your message"
              />
            </div>
          </form>
        </div>
      </div>
      <div av-modal-footer>
        <button av-button variant="secondary" av-modal-close>Cancel</button>
        <button av-button av-modal-close>Send Message</button>
      </div>
    </div>
  </ng-template>
</av-modal>`,
})
export class ModalWithFormDemo {}

Custom Trigger

Apply av-modal-trigger to any focusable element — cards, links, or icon buttons — instead of a plain button.

import { Component } from '@angular/core';
import {
  AvButtonComponent,
  AvModalImports,
} from '@avesra/angular';
import { AppIconComponent } from '../../components/app-icon/app-icon.component';

@Component({
  selector: 'app-modal-custom-trigger-demo',
  imports: [
    AppIconComponent,
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<av-modal>
  <div
    av-modal-trigger
    class="group flex max-w-xs cursor-pointer items-center gap-3 rounded-2xl bg-surface p-4 shadow-xs select-none hover:bg-surface-secondary"
  >
    <div
      class="flex size-12 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-soft-foreground"
    >
      <app-icon icon="solar:settings-linear" size="24" />
    </div>
    <div class="flex flex-1 flex-col gap-0.5">
      <p class="text-sm font-semibold">Settings</p>
      <p class="text-xs text-muted">Manage your preferences</p>
    </div>
  </div>
  <ng-template avModalContent>
    <div av-modal-dialog class="sm:max-w-[360px]">
      <av-modal-close-trigger />
      <div av-modal-header>
        <div av-modal-icon class="bg-accent-soft text-accent-soft-foreground">
          <app-icon icon="solar:settings-linear" size="20" />
        </div>
        <h2 av-modal-heading>Settings</h2>
      </div>
      <div av-modal-body>
        <p>
          Use <code>av-modal-trigger</code> on any focusable element to create custom triggers
          beyond standard buttons. This example shows a card-style trigger with an icon and
          descriptive text.
        </p>
      </div>
      <div av-modal-footer>
        <button av-button variant="secondary" av-modal-close>Cancel</button>
        <button av-button av-modal-close>Save</button>
      </div>
    </div>
  </ng-template>
</av-modal>`,
})
export class ModalCustomTriggerDemo {}

Styling

Passing Tailwind CSS classes

Style the overlay chrome with backdrop-class and container-class on av-modal, and pass utility classes directly on div[av-modal-dialog] and the other part hosts.

import { Component } from '@angular/core';
import {
  AvButtonComponent,
  AvModalImports,
} from '@avesra/angular';

@Component({
  selector: 'app-modal-custom-styling-demo',
  imports: [
    AvModalImports,
    AvButtonComponent,
  ],
  template: `<av-modal backdrop-class="bg-black/80" container-class="items-start pt-20">
  <button av-button variant="secondary" av-modal-trigger>Open Modal</button>
  <ng-template avModalContent>
    <div
      av-modal-dialog
      class="bg-linear-to-br from-accent to-accent-soft text-accent-foreground sm:max-w-[360px]"
    >
      <av-modal-close-trigger />
      <div av-modal-header>
        <h2 av-modal-heading>Custom Styled Modal</h2>
      </div>
      <div av-modal-body>
        <p>
          This modal has custom styling applied via <code>backdrop-class</code> and
          <code>container-class</code> on the root, plus utility classes on the dialog host.
        </p>
      </div>
      <div av-modal-footer>
        <button av-button av-modal-close>Close</button>
      </div>
    </div>
  </ng-template>
</av-modal>`,
})
export class ModalCustomStylingDemo {}

Customizing the component classes

To customize the Modal classes, use the @layer components directive. Learn more.

@layer components {
  .av-modal__backdrop {
    @apply bg-gradient-to-br from-black/50 to-black/70;
  }

  .av-modal__dialog {
    @apply rounded-2xl border border-white/10 shadow-2xl;
  }

  .av-modal__header {
    @apply text-center;
  }

  .av-modal__close-trigger {
    @apply rounded-full bg-white/10 hover:bg-white/20;
  }
}

Avesra follows the BEM methodology so component variants and states stay reusable and easy to customize.

CSS Classes

The Modal component uses these CSS classes:

Base Classes

  • .av-modal__trigger — Trigger element that opens the modal
  • .av-modal__backdrop — Overlay backdrop behind the modal
  • .av-modal__container — Positioning wrapper with placement support
  • .av-modal__dialog — Modal content container
  • .av-modal__header — Header section for titles and icons
  • .av-modal__heading — Dialog title
  • .av-modal__icon — Optional header icon
  • .av-modal__body — Main content area
  • .av-modal__footer — Footer section for actions
  • .av-modal__close-trigger — Close button element

Backdrop Variants

  • .av-modal__backdrop--opaque — Opaque colored backdrop (default)
  • .av-modal__backdrop--blur — Blurred backdrop with glass effect
  • .av-modal__backdrop--transparent — Transparent backdrop (no overlay)

Scroll Variants

  • .av-modal__container--scroll-outside — Enables scrolling the entire modal
  • .av-modal__dialog--scroll-inside — Constrains modal height for body scrolling
  • .av-modal__body--scroll-inside — Makes only the body scrollable
  • .av-modal__body--scroll-outside — Allows full-page scrolling

Interactive States

The component supports these interactive states:

  • Focus::focus-visible or [data-focus-visible="true"] — Applied to trigger, dialog, and close button
  • Hover::hover or [data-hovered="true"] — Applied to close button on hover
  • Active::active or [data-pressed="true"] — Applied to close button when pressed
  • Entering:[data-entering] — During opening animation
  • Exiting:[data-exiting] — During closing animation
  • Placement:[data-placement="*"] — Modal position (auto, top, center, bottom)

API Reference

Overlay configuration lives on av-modal; the dialog and its child parts are composed inside ng-template avModalContent. Close actions use av-modal-close on interactive elements.

PropTypeDefaultDescription
openbooleanfalseControls whether the modal is open. Supports two-way binding with [(open)] (av-modal).
dismissablebooleantrueWhether clicking the backdrop closes the modal (av-modal).
keyboard-dismiss-disabledbooleanfalseDisables closing via the Escape key (av-modal).
backdrop'opaque' | 'blur' | 'transparent''opaque'Backdrop visual variant (av-modal).
backdrop-classstring''Extra CSS classes merged onto the visual backdrop after the variant BEM classes (av-modal).
container-classstring''Extra CSS classes merged onto the overlay container after the BEM classes — useful for custom enter/exit motion (av-modal).
placement'auto' | 'top' | 'center' | 'bottom''auto'Modal position on screen (av-modal).
scroll'inside' | 'outside''inside'Scroll behavior for long content (av-modal).
size'xs' | 'sm' | 'md' | 'lg' | 'cover' | 'full''md'Maximum width / layout preset (av-modal).
scroll'inside' | 'outside' | undefined—Scroll behavior. Inherits from av-modal when omitted (div[av-modal-dialog]).
size'xs' | 'sm' | 'md' | 'lg' | 'cover' | 'full' | undefined—Size preset. Inherits from av-modal when omitted (div[av-modal-dialog]).
aria-labelstring—Accessible label when the trigger has no visible text ([av-modal-trigger]).

Accessibility

Implements the WAI-ARIA Dialog pattern:

  • Focus trap: Focus locked within the modal
  • Keyboard:Escape closes (when enabled), Tab cycles elements
  • Screen readers: Proper ARIA attributes on the dialog
  • Scroll lock: Body scroll disabled when open

Made with ❤ by SyntaxHertz. Open source and available on GitHub.