Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Drawer

A side navigation drawer that slides in from an edge.

A side navigation drawer that slides in from the start or end edge over a scrim. It uses an opaque surface (glass is not idiomatic for a nav drawer); MD3 rounds the trailing corners. Content is free-form — typically a list.

Installation

npx touchcn add drawer

Usage

<tcn-drawer [(open)]="open" side="start">
  <tcn-list>…</tcn-list>
</tcn-drawer>
import { TcnDrawer } from '@/components/ui/drawer';

<TcnDrawer open={open} onOpenChange={setOpen} side="start">
  <TcnList>…</TcnList>
</TcnDrawer>
<script setup lang="ts">
import { TcnDrawer } from '@/components/ui/drawer';
import { TcnList } from '@/components/ui/list';
</script>

<template>
  <TcnDrawer v-model:open="open" side="start">
    <TcnList>…</TcnList>
  </TcnDrawer>
</template>

Props

Prop Type Default Description
open boolean false Open state (two-way bound on Angular).
onOpenChange (open: boolean) => void Open-state callback (React).
side 'start' | 'end' 'start' Edge the drawer slides in from.
title string 'Drawer' Accessible label announced on open (React).
Source · Angular
export * from './tcn-drawer';
import { Component, computed, input, model, output } from '@angular/core';
import { TcnOverlayDirective } from '@touchcn/angular/overlay';

export type TcnDrawerSide = 'start' | 'end';

/**
 * A side navigation drawer that slides in from the start (default) or end edge
 * over a scrim. Composes the shared overlay primitive (focus trap, scroll lock,
 * Escape). Content is free-form — apps typically place a list inside.
 */
@Component({
  selector: 'tcn-drawer',
  imports: [TcnOverlayDirective],
  template: `
    <div class="fixed inset-0 z-50" [class.pointer-events-none]="!open()" [attr.data-state]="state()">
      <div class="tcn-overlay-backdrop absolute inset-0 bg-black/40" (click)="dismiss()"></div>
      <!-- Keyed by side so a side switch remounts the panel: a fresh element
           starts at its own side's closed transform, so the first open after a
           side change never animates in from the previous side's edge. -->
      @for (edge of [side()]; track edge) {
        <div
          tcnOverlay
          [(open)]="open"
          (dismissed)="closed.emit()"
          [attr.data-side]="edge"
          class="tcn-overlay-panel tcn-drawer-panel absolute inset-y-0 overflow-y-auto"
        >
          <ng-content />
        </div>
      }
    </div>
  `,
})
export class TcnDrawer {
  readonly open = model(false);
  readonly side = input<TcnDrawerSide>('start');
  readonly closed = output<void>();

  protected readonly state = computed(() => (this.open() ? 'open' : 'closed'));

  protected dismiss(): void {
    if (this.open()) {
      this.open.set(false);
      this.closed.emit();
    }
  }
}
Source · React
export * from './tcn-drawer';
import type { ReactNode } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { useOverlayPresence } from '@touchcn/react';

export type TcnDrawerSide = 'start' | 'end';

export interface TcnDrawerProps {
  open: boolean;
  onOpenChange(open: boolean): void;
  /** Edge the drawer slides in from. */
  side?: TcnDrawerSide;
  /** Accessible label (visually hidden) announced when the drawer opens. */
  title?: string;
  children?: ReactNode;
}

/**
 * A side navigation drawer that slides in from the start (default) or end edge
 * over a scrim. Built on Radix Dialog for the portal, focus trap, scroll lock,
 * Escape and the modal ARIA contract. Content is free-form — apps typically
 * place a list inside.
 */
export function TcnDrawer({ open, onOpenChange, side = 'start', title = 'Drawer', children }: TcnDrawerProps) {
  const { present, active, panelRef } = useOverlayPresence(open);
  if (!present) {
    return null;
  }
  const state = active ? 'open' : 'closed';
  return (
    <Dialog.Root open onOpenChange={(next) => !next && onOpenChange(false)}>
      <Dialog.Portal>
        <div className="fixed inset-0 z-50" data-state={state}>
          <Dialog.Overlay className="tcn-overlay-backdrop absolute inset-0 bg-black/40" />
          <Dialog.Content
            ref={panelRef}
            aria-describedby={undefined}
            data-side={side}
            className="tcn-overlay-panel tcn-drawer-panel absolute inset-y-0 overflow-y-auto"
          >
            <Dialog.Title className="sr-only">{title}</Dialog.Title>
            {children}
          </Dialog.Content>
        </div>
      </Dialog.Portal>
    </Dialog.Root>
  );
}
Source · Vue
<script lang="ts">
export type TcnDrawerSide = 'start' | 'end';
</script>

<script setup lang="ts">
import { computed } from 'vue';
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui';
import { useOverlayPresence } from '@touchcn/vue';

const props = withDefaults(defineProps<{ side?: TcnDrawerSide; title?: string }>(), {
  side: 'start',
  title: 'Drawer',
});

/** Two-way bound open state (`v-model:open`). */
const open = defineModel<boolean>('open', { default: false });

/**
 * A side navigation drawer that slides in from the start (default) or end edge
 * over a scrim. Built on reka-ui Dialog for the portal, focus trap, scroll lock,
 * Escape and the modal ARIA contract. Content is free-form — apps typically
 * place a list inside.
 *
 * The panel is keyed by `side` so a side switch remounts it: a fresh element
 * starts at its own side's closed transform, so the first open after a side
 * change never animates in from the previous side's edge (mirrors the Angular
 * `@for (edge of [side()])` remount guard).
 */
const { present, active, setPanel } = useOverlayPresence(open);
const state = computed(() => (active.value ? 'open' : 'closed'));

const onRekaUpdate = (next: boolean): void => {
  if (!next) {
    open.value = false;
  }
};
</script>

<template>
  <DialogRoot v-if="present" :open="true" @update:open="onRekaUpdate">
    <DialogPortal>
      <div class="fixed inset-0 z-50" :data-state="state">
        <DialogOverlay class="tcn-overlay-backdrop absolute inset-0 bg-black/40" />
        <DialogContent
          :key="props.side"
          :ref="setPanel"
          :aria-describedby="undefined"
          :data-side="props.side"
          class="tcn-overlay-panel tcn-drawer-panel absolute inset-y-0 overflow-y-auto"
        >
          <DialogTitle class="sr-only">{{ title }}</DialogTitle>
          <slot />
        </DialogContent>
      </div>
    </DialogPortal>
  </DialogRoot>
</template>
export { default as TcnDrawer } from './TcnDrawer.vue';
export type { TcnDrawerSide } from './TcnDrawer.vue';

Last updated on July 24, 2026

Was this page helpful?