<link rel="stylesheet" href="assets/fonts/inter.css"> Angular CDK Explained: Overlay, A11y and Drag-Drop… | Viral Patel Studio Skip to main content

Angular CDK Explained: Overlay, A11y and Drag-Drop (2026)

Oct 25, 2026By Viral Patel

Angular CDK explained for 2026: overlay, portals, focus management, drag-drop, virtual scroll and when to pick the CDK over Material or PrimeNG.

Angular CDK Explained: Overlay, A11y and Drag-Drop (2026)

Every Angular Dropdown You Have Used Runs on the Same Code

Open a Material select, a menu, a dialog or a tooltip and you are looking at the same layer of behavior. Positioning, stacking, focus handling, keyboard navigation and drag logic all come from one package, and the Angular CDK is where that package lives.

learn about Angular Development

angular development

PrimeNG vs Angular Material (2026): Which to Choose?

Most developers meet the Angular CDK only as a dependency of Material. That undersells it. It is a toolkit of unstyled behavior primitives you can use directly, with your own markup and your own design tokens. In Angular 22 (released June 2026) the CDK is at 22.2.x, overlays use the browser Popover API, and a new sibling, Angular Aria, builds complete accessible patterns on top.

design systems

The CDK is where Angular keeps the hard parts of UI, and you can use them without adopting Material's look. This guide walks through the modules that matter, with standalone, signals-era code.


Section 1: What the Angular CDK Is (and What It Is Not)

The CDK is a set of behavior primitives. It ships no colors, no typography and no Material Design opinions. Install it with:

ng add @angular/cdk

Angular's team frames the stack in three tiers: Angular Aria for accessible headless patterns, the CDK for behavior primitives, and Angular Material for the styled library. Keeping that picture in mind avoids most confusion.

Angular CDK vs Angular Material

Angular CDK Angular Material
Visual styling None Material Design
Package @angular/cdk @angular/material
Best for Custom design systems Ready-made, consistent UI
Effort You write the templates and CSS You configure a theme

For a full library comparison including PrimeNG, read Angular Material vs PrimeNG vs CDK: which UI library to pick. This article goes deeper on how the CDK itself works.

Angular CDK vs Angular Aria

Angular Aria (@angular/aria) arrived as a developer preview in Angular 21 and is stable in Angular 22. It offers twelve headless patterns, including tabs, listbox, menu, combobox, tree and grid. Each handles keyboard interaction, ARIA attributes and focus for you. The CDK is the lower layer: you assemble behavior yourself. Aria even reuses CDK pieces, for example its select pattern positions its popup with cdkConnectedOverlay.

Structural styles you must add

Outside Material, overlays need the CDK's structural CSS or they will render in the wrong place. Use the Sass mixin rather than a deprecated @import:

@use '@angular/cdk' as cdk;

@include cdk.overlay();

Section 2: Overlay and Portal

The overlay system is the most used part of the CDK. It renders content in a container at the end of the document, so it escapes overflow: hidden parents and z-index fights.

The mental model

Calling overlay.create() returns an OverlayRef. You attach a portal to it, which is a description of what to render: a component (ComponentPortal) or a template (TemplatePortal). A position strategy decides where it goes and a scroll strategy decides what happens on scroll.

  • Global position strategy: for modals and notifications that sit relative to the viewport.
  • Flexible connected strategy: for menus and tooltips anchored to an element, with fallback positions when there is no room.
  • Scroll strategies: noop (the default), close, block and reposition.

A declarative dropdown

For most dropdowns, the directives are enough:

import { Component, signal } from '@angular/core';
import {
  CdkConnectedOverlay,
  CdkOverlayOrigin,
  ConnectedPosition,
} from '@angular/cdk/overlay';

@Component({
  selector: 'app-user-menu',
  imports: [CdkOverlayOrigin, CdkConnectedOverlay],
  template: `
    <button
      type="button"
      cdkOverlayOrigin
      #origin="cdkOverlayOrigin"
      aria-haspopup="menu"
      [attr.aria-expanded]="open()"
      (click)="open.set(!open())"
    >
      Account
    </button>

    <ng-template
      cdkConnectedOverlay
      [cdkConnectedOverlayOrigin]="origin"
      [cdkConnectedOverlayOpen]="open()"
      [cdkConnectedOverlayPositions]="positions"
      (overlayOutsideClick)="open.set(false)"
      (detach)="open.set(false)"
    >
      <div class="panel" role="menu">
        <button role="menuitem" type="button">Profile</button>
        <button role="menuitem" type="button">Sign out</button>
      </div>
    </ng-template>
  `,
})
export class UserMenu {
  readonly open = signal(false);
  readonly positions: ConnectedPosition[] = [
    { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 },
    { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 },
  ];
}

The second position is the fallback: if the panel does not fit below the button, it flips above. The open state is a plain signal, which keeps the template trivial.

A programmatic overlay with cleanup

When you need full control, inject Overlay and build the ref yourself. The common mistakes are leaking the ref and re-subscribing to backdrop events on every open, so create everything once:

import {
  Component, DestroyRef, ElementRef, TemplateRef,
  ViewContainerRef, inject, viewChild,
} from '@angular/core';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';

@Component({
  selector: 'app-popover-demo',
  template: `
    <button #trigger type="button" (click)="toggle()">Toggle</button>
    <ng-template #panel>
      <div role="dialog" aria-label="Details">Hello overlay</div>
    </ng-template>
  `,
})
export class PopoverDemo {
  private readonly overlay = inject(Overlay);
  private readonly vcr = inject(ViewContainerRef);
  private readonly trigger = viewChild.required<ElementRef<HTMLElement>>('trigger');
  private readonly panel = viewChild.required<TemplateRef<unknown>>('panel');
  private ref?: OverlayRef;

  constructor() {
    inject(DestroyRef).onDestroy(() => this.ref?.dispose());
  }

  toggle() {
    if (!this.ref) {
      this.ref = this.overlay.create({
        positionStrategy: this.overlay
          .position()
          .flexibleConnectedTo(this.trigger())
          .withPositions([
            { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' },
          ]),
        scrollStrategy: this.overlay.scrollStrategies.reposition(),
        hasBackdrop: true,
        backdropClass: 'cdk-overlay-transparent-backdrop',
      });
      this.ref.backdropClick().subscribe(() => this.ref?.detach());
      this.ref.keydownEvents().subscribe(e => {
        if (e.key === 'Escape') this.ref?.detach();
      });
    }
    if (this.ref.hasAttached()) {
      this.ref.detach();
    } else {
      this.ref.attach(new TemplatePortal(this.panel(), this.vcr));
    }
  }
}

For a centered modal-style layer, swap the position strategy for overlay.position().global().centerHorizontally().centerVertically() and use the block scroll strategy.

The Popover API change in v21

Since Angular 21, CDK overlays render through the browser Popover API and its top layer by default. That fixes a whole class of stacking bugs. It also introduced reported side effects: conflicts with non-CDK overlays and with the Fullscreen API (see angular/components issue 32729). If a third-party layer or fullscreen element misbehaves after upgrading, check the overlay API documentation for the usePopover option and how to opt out in your version. I have not verified the exact opt-out provider syntax, so confirm it against the docs for your release.


Section 3: Accessibility Tools in @angular/cdk/a11y

Accessibility is where the CDK saves the most time. Four tools cover most needs. For the design-side reasoning behind them, see the keyboard navigation and focus management guide.

accessibility

FocusTrap

cdkTrapFocus keeps Tab and Shift+Tab inside a region. Add cdkTrapFocusAutoCapture to move focus in when the region appears and restore it when it goes away, and mark the first target with cdkFocusInitial:

<div cdkTrapFocus [cdkTrapFocusAutoCapture]="true"
     role="dialog" aria-modal="true" aria-label="Edit profile">
  <input cdkFocusInitial placeholder="Name" />
  <button type="button">Save</button>
</div>

Import CdkTrapFocus in your standalone component. Note that a focus trap does not stop focus leaving by mouse click. For real modals, prefer the CDK Dialog service (covered below), which traps and restores focus for you.

LiveAnnouncer

Screen readers do not notice content that changes silently. LiveAnnouncer writes to a visually hidden live region:

import { Component, inject, signal } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';

@Component({
  selector: 'app-cart-button',
  template: `<button type="button" (click)="add()">Add to cart ({{ count() }})</button>`,
})
export class CartButton {
  private readonly announcer = inject(LiveAnnouncer);
  readonly count = signal(0);

  add() {
    this.count.update(c => c + 1);
    this.announcer.announce(`Item added. ${this.count()} items in cart`, 'polite');
  }
}

Use polite for routine updates and reserve assertive for errors that cannot wait.

ListKeyManager

Composite widgets such as listboxes, menus and tabs need arrow-key navigation. ListKeyManager handles it, with two flavors. FocusKeyManager moves real DOM focus between items that implement focus(). ActiveDescendantKeyManager keeps focus on the container and updates aria-activedescendant. There is also a TreeKeyManager for trees.

import { AfterViewInit, Component, Directive, ElementRef, inject, viewChildren } from '@angular/core';
import { FocusKeyManager, FocusableOption } from '@angular/cdk/a11y';

@Directive({ selector: '[appItem]', host: { tabindex: '-1', role: 'option' } })
export class Item implements FocusableOption {
  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef);
  focus() { this.el.nativeElement.focus(); }
}

@Component({
  selector: 'app-list',
  imports: [Item],
  host: { role: 'listbox', tabindex: '0', '(keydown)': 'onKeydown($event)' },
  template: `<div appItem>One</div><div appItem>Two</div><div appItem>Three</div>`,
})
export class List implements AfterViewInit {
  private readonly items = viewChildren(Item);
  private keyManager!: FocusKeyManager<Item>;

  ngAfterViewInit() {
    this.keyManager = new FocusKeyManager<Item>(this.items()).withWrap();
  }
  onKeydown(e: KeyboardEvent) { this.keyManager.onKeydown(e); }
}

The signal-based viewChildren returns an array here. Older code passes a QueryList, and the manager accepts both in current releases, but check the type signature for your version if the compiler complains.

FocusMonitor

FocusMonitor tells you how an element received focus: mouse, keyboard, touch, program or null. That lets you draw strong focus rings for keyboard users without flashing them on every click. The cdkMonitorElementFocus directive applies classes such as .cdk-keyboard-focused automatically. If you use the service directly, call stopMonitoring on destroy.

Styling helpers

The CDK also ships a Sass visually-hidden mixin (and the .cdk-visually-hidden class) and a high-contrast mixin that targets forced-colors, so you can support Windows High Contrast without hand-rolling media queries.


Section 4: Drag and Drop

@angular/cdk/drag-drop replaces a lot of third-party sortable libraries. The key rule: the directives move elements visually, but they never change your data. You update the model in the drop handler.

A sortable list with signals

import { Component, signal } from '@angular/core';
import {
  CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, moveItemInArray,
} from '@angular/cdk/drag-drop';

@Component({
  selector: 'app-tasks',
  imports: [CdkDropList, CdkDrag, CdkDragHandle],
  template: `
    <ul cdkDropList (cdkDropListDropped)="drop($event)">
      @for (t of tasks(); track t) {
        <li cdkDrag>
          <span cdkDragHandle aria-hidden="true">::</span> {{ t }}
        </li>
      }
    </ul>
  `,
})
export class Tasks {
  readonly tasks = signal(['Write', 'Review', 'Publish']);

  drop(e: CdkDragDrop<string[]>) {
    this.tasks.update(list => {
      const next = [...list];
      moveItemInArray(next, e.previousIndex, e.currentIndex);
      return next;
    });
  }
}

Copying the array first matters. moveItemInArray mutates in place, and a signal will not notice an in-place change.

Connected lists and copy mode

Wrap lists in cdkDropListGroup and any child lists become connected, or use cdkDropListConnectedTo for explicit control. Use transferArrayItem to move between lists. Newer CDK versions also support copying: combine cdkDropListHasAnchor with cdkDropListSortingDisabled on a source list to build a product catalog that fills a cart without removing items from the catalog. Pair it with copyArrayItem in the drop handler.

Tuning the feel

  • Handles: cdkDragHandle restricts dragging to a grip element.
  • Previews and placeholders: customize with cdkDragPreview and cdkDragPlaceholder, and style the .cdk-drag-preview and .cdk-drag-placeholder classes.
  • Constraints: boundary elements, axis locking and start delays are inputs on cdkDrag.
  • Wrapping lists: cdkDropListOrientation="mixed" supports grids that wrap, at the cost of the sort animation.
  • Rules: cdkDropListEnterPredicate and cdkDropListSortPredicate decide what may land where.

Gotchas

Auto-scroll inside a scrollable container needs cdkScrollable on that container. And as a matter of advice rather than documented behavior, offer a non-drag alternative such as move up and move down buttons. Pointer-driven dragging alone excludes keyboard and switch users.


Section 5: Virtual Scrolling

Rendering 100,000 rows creates 100,000 DOM nodes. Virtual scrolling renders only what is visible plus a small buffer.

import { Component } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  selector: 'app-big-list',
  imports: [ScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport itemSize="48" class="viewport">
      <div *cdkVirtualFor="let row of rows; trackBy: trackById" class="row">
        {{ row.name }}
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styles: `
    .viewport { height: 400px; }
    .row { height: 48px; }
  `,
})
export class BigList {
  readonly rows = Array.from({ length: 100_000 }, (_, id) => ({ id, name: `Row ${id}` }));
  trackById = (_: number, r: { id: number }) => r.id;
}

Three rules prevent most bugs:

  1. Give the viewport an explicit height. Without one it has nothing to virtualize against.
  2. Set itemSize. The stable strategy assumes fixed-height items. An autosize strategy exists only in @angular/cdk-experimental and is not production-ready.
  3. Use *cdkVirtualFor, not @for. It is a structural directive with its own recycling logic, so the built-in control flow does not replace it.

Useful extras include minBufferPx and maxBufferPx for tuning render-ahead, appendOnly for growing lists, a horizontal orientation, and scrollWindow when the page itself scrolls.


Section 6: Other CDK Packages Worth Knowing

You do not need all of these on day one, but knowing they exist saves you from installing a library for each.

Dialog and Menu

@angular/cdk/dialog is an unstyled modal service. dialog.open(Component, config) returns a DialogRef, data arrives via the DIALOG_DATA token, and the dialog sets a role, supports ariaLabel, moves focus in with autoFocus, restores it on close with restoreFocus, and closes on Escape unless you set disableClose. One detail: providing DEFAULT_DIALOG_CONFIG replaces the built-in defaults rather than merging, so spread a new DialogConfig() into it.

@angular/cdk/menu provides cdkMenuTriggerFor, cdkMenu, cdkMenuItem, menu bars, checkbox and radio items, and context menus through cdkContextMenuTriggerFor. Roles and keyboard handling are automatic. Checked state for checkbox and radio items is yours to bind.

Clipboard and layout

Clipboard copies text with one call, and cdkCopyToClipboard does it declaratively. BreakpointObserver pairs well with signals:

import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { map } from 'rxjs';

@Component({
  selector: 'app-shell',
  template: `@if (isHandset()) { <p>Mobile layout</p> } @else { <p>Desktop layout</p> }`,
})
export class Shell {
  readonly isHandset = toSignal(
    inject(BreakpointObserver).observe(Breakpoints.Handset).pipe(map(r => r.matches)),
    { initialValue: false },
  );
}

Table, stepper, listbox, tree and testing

The CDK also contains unstyled table, stepper, listbox and tree building blocks, which Material builds on. Component test harnesses live in @angular/cdk/testing, and Angular Aria's harnesses build on them. For a design-system team, harnesses are worth adopting early because tests keep working when your internal markup changes. If you are packaging these components, the guide to publishing an Angular component library covers the release side.


Section 7: CDK vs Material vs PrimeNG, and When to Use Which

The honest answer depends on how much of the visual layer you want to own.

Situation Best fit
Internal tool, fast delivery, Material look is fine Angular Material
Data-heavy app that needs a broad component catalog now PrimeNG
Custom brand, you own the design tokens CDK
Custom brand and standard widgets like tabs or listbox Angular Aria (on CDK)
One tricky behavior, such as a popover or sortable list A single CDK module

A hybrid is common and healthy: use a styled library for the bulk of the UI and reach for the CDK where you need something it does not provide, or where you want to restyle one interaction. If you are building the token and component layers yourself, the step-by-step Angular design system guide shows the structure, and the signals guide for design system components shows how to model state inside them.


Section 8: When NOT to Use the CDK

The CDK is a power tool, and power tools have costs.

  • You need finished UI quickly. The CDK gives you zero visuals. If a Material or PrimeNG component already does the job, adopting the CDK means rebuilding what you could have installed.
  • The behavior is a native element. A plain <select>, <dialog> or <details> may serve you better, with less code and built-in accessibility.
  • Nobody owns the accessibility. Primitives help, but they do not make a widget accessible on their own. If your team cannot test with a keyboard and a screen reader, a complete styled library or an Angular Aria pattern is safer.
  • A full pattern already exists. If you need tabs, a listbox or a combobox, check Angular Aria first rather than assembling the same thing from ListKeyManager and overlays.
  • You are chasing marginal wins. Do not add virtual scrolling to a 40-row table.

Conclusion: Use the CDK Where Behavior Is Hard and Looks Are Yours

The Angular CDK earns its place when interaction logic is difficult and the visual design is specific to you. Overlays, focus management, drag and drop, and virtual scrolling are exactly that kind of problem: tedious to get right, easy to get subtly wrong, and invisible when done well.

Start with one module. Replace a hand-rolled dropdown with cdkConnectedOverlay, wrap a dialog in cdkTrapFocus, or swap a sortable library for cdkDropList. Dispose your overlays, update your data in drop handlers, and give your viewports a height. Then widen from there, and check Angular Aria before you build any standard widget from scratch.

Let the CDK own the behavior and let your design system own the look.