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';