Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Modal

A full-screen surface that slides up over the page.

A full-screen surface for a focused, self-contained task. It slides up over the page (iOS page-sheet feel; MD3 full-screen dialog) and traps focus until dismissed. Provide your own header with a leading Close control.

Installation

npx touchcn add modal

Usage

<tcn-modal [(open)]="open">
  <header>…leading Close control…</header>
  <div class="p-4">Content</div>
</tcn-modal>
import { TcnModal } from '@/components/ui/modal';

<TcnModal open={open} onOpenChange={setOpen} title="New Message">
  <header>…leading Close control…</header>
  <div className="p-4">Content</div>
</TcnModal>
<script setup lang="ts">
import { TcnModal } from '@/components/ui/modal';
</script>

<template>
  <TcnModal v-model:open="open" title="New Message">
    <header>…leading Close control…</header>
    <div class="p-4">Content</div>
  </TcnModal>
</template>

Props

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

/**
 * A full-screen surface that slides up over the page. Composes the shared
 * overlay primitive (focus trap, scroll lock, Escape). The header — typically a
 * navbar with a leading Close control — is app-supplied via the default slot,
 * alongside the body content.
 */
@Component({
  selector: 'tcn-modal',
  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)="overlay.dismiss()"></div>
      <div
        #overlay="tcnOverlay"
        tcnOverlay
        [(open)]="open"
        (dismissed)="closed.emit()"
        class="tcn-overlay-panel tcn-modal-panel absolute inset-0 flex flex-col overflow-hidden"
      >
        <div class="flex-1 overflow-y-auto">
          <ng-content />
        </div>
      </div>
    </div>
  `,
})
export class TcnModal {
  readonly open = model(false);
  readonly closed = output<void>();

  protected readonly state = computed(() => (this.open() ? 'open' : 'closed'));
}
Source · React
export * from './tcn-modal';
import type { ReactNode } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { useOverlayPresence } from '@touchcn/react';

export interface TcnModalProps {
  open: boolean;
  onOpenChange(open: boolean): void;
  /** Accessible label (visually hidden) announced when the modal opens. */
  title?: string;
  children?: ReactNode;
}

/**
 * A full-screen surface that slides up over the page. Built on Radix Dialog for
 * the portal, focus trap, scroll lock, Escape and the modal ARIA contract. The
 * header — typically a navbar with a leading Close control — is supplied as
 * children, alongside the body content.
 */
export function TcnModal({ open, onOpenChange, title = 'Modal', children }: TcnModalProps) {
  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}
            className="tcn-overlay-panel tcn-modal-panel absolute inset-0 flex flex-col overflow-hidden"
          >
            <Dialog.Title className="sr-only">{title}</Dialog.Title>
            <div className="flex-1 overflow-y-auto">{children}</div>
          </Dialog.Content>
        </div>
      </Dialog.Portal>
    </Dialog.Root>
  );
}
Source · Vue
<script setup lang="ts">
import { computed } from 'vue';
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui';
import { useOverlayPresence } from '@touchcn/vue';

withDefaults(defineProps<{ title?: string }>(), { title: 'Modal' });

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

/**
 * A full-screen surface that slides up over the page. Built on reka-ui Dialog
 * for the portal, focus trap, scroll lock, Escape and the modal ARIA contract;
 * `useOverlayPresence` keeps it mounted through the exit transition. The header
 * — typically a navbar with a leading Close control — is supplied as the default
 * slot, alongside the body content.
 */
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
          :ref="setPanel"
          :aria-describedby="undefined"
          class="tcn-overlay-panel tcn-modal-panel absolute inset-0 flex flex-col overflow-hidden"
        >
          <DialogTitle class="sr-only">{{ title }}</DialogTitle>
          <div class="flex-1 overflow-y-auto"><slot /></div>
        </DialogContent>
      </div>
    </DialogPortal>
  </DialogRoot>
</template>
export { default as TcnModal } from './TcnModal.vue';

Last updated on July 24, 2026

Was this page helpful?