Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Dialog

A modal overlay for focused tasks or confirmations.

A modal overlay for focused tasks or confirmations, with a platform-adaptive action row.

Installation

npx touchcn add dialog

Usage

<tcn-dialog
  [(open)]="open"
  heading="Delete file?"
  message="This action cannot be undone."
/>
import { TcnDialog } from '@/components/ui/dialog';
import { TcnButton } from '@/components/ui/button';

<TcnDialog
  open={open}
  onOpenChange={setOpen}
  heading="Delete file?"
  message="This action cannot be undone."
  actions={<TcnButton variant="destructive">Delete</TcnButton>}
/>
<script setup lang="ts">
import { TcnDialog } from '@/components/ui/dialog';
import { TcnButton } from '@/components/ui/button';
</script>

<template>
  <TcnDialog
    v-model:open="open"
    heading="Delete file?"
    message="This action cannot be undone."
  >
    <template #actions>
      <TcnButton variant="destructive">Delete</TcnButton>
    </template>
  </TcnDialog>
</template>

Props

Prop Type Default Description
open boolean false Open state (two-way bound on Angular).
onOpenChange (open: boolean) => void Open-state callback (React).
heading string '' Dialog heading.
message string '' Body text.
actions ReactNode Action row (typically TcnButtons) — React.
Source · Angular
export * from './tcn-dialog';
import { Component, computed, input, model, output } from '@angular/core';
import { TcnOverlayDirective } from '@touchcn/angular/overlay';

@Component({
  selector: 'tcn-dialog',
  imports: [TcnOverlayDirective],
  template: `
    <div
      class="fixed inset-0 z-50 flex items-center justify-center p-6"
      [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-dialog-panel tcn-material relative w-full max-w-[300px] overflow-hidden rounded-[var(--radius-lg)] text-[var(--color-on-surface)]"
      >
        <div class="tcn-dialog-body px-5 pt-5 pb-4 text-center">
          @if (heading()) {
            <h2 class="text-[17px] font-semibold">{{ heading() }}</h2>
          }
          @if (message()) {
            <p class="mt-1 text-sm text-[var(--color-on-surface-variant)]">{{ message() }}</p>
          }
          <ng-content />
        </div>
        <div class="tcn-dialog-actions">
          <ng-content select="[dialog-action]" />
        </div>
      </div>
    </div>
  `,
})
export class TcnDialog {
  readonly open = model(false);
  readonly heading = input('');
  readonly message = input('');
  readonly closed = output<void>();

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

export interface TcnDialogProps {
  open: boolean;
  onOpenChange(open: boolean): void;
  heading?: string;
  message?: string;
  /** Action row (typically `TcnButton`s); re-skinned per platform by CSS. */
  actions?: ReactNode;
  children?: ReactNode;
}

/**
 * A modal overlay for focused tasks or confirmations. iOS splits the actions
 * with hairlines (classic alert); MD right-aligns them. Built on Radix Dialog;
 * the heading maps to `Dialog.Title` (rendered as an `<h2>`, matching Angular).
 */
export function TcnDialog({ open, onOpenChange, heading, message, actions, children }: TcnDialogProps) {
  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 flex items-center justify-center p-6" 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-dialog-panel tcn-material relative w-full max-w-[300px] overflow-hidden rounded-[var(--radius-lg)] text-[var(--color-on-surface)]"
          >
            <div className="tcn-dialog-body px-5 pt-5 pb-4 text-center">
              {heading ? (
                <Dialog.Title className="text-[17px] font-semibold">{heading}</Dialog.Title>
              ) : (
                <Dialog.Title className="sr-only">Dialog</Dialog.Title>
              )}
              {message && <p className="mt-1 text-sm text-[var(--color-on-surface-variant)]">{message}</p>}
              {children}
            </div>
            <div className="tcn-dialog-actions">{actions}</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';

defineProps<{ heading?: string; message?: string }>();

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

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 flex items-center justify-center p-6" :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-dialog-panel tcn-material relative w-full max-w-[300px] overflow-hidden rounded-[var(--radius-lg)] text-[var(--color-on-surface)]"
        >
          <div class="tcn-dialog-body px-5 pt-5 pb-4 text-center">
            <DialogTitle v-if="heading" class="text-[17px] font-semibold">{{ heading }}</DialogTitle>
            <DialogTitle v-else class="sr-only">Dialog</DialogTitle>
            <p v-if="message" class="mt-1 text-sm text-[var(--color-on-surface-variant)]">{{ message }}</p>
            <slot />
          </div>
          <div class="tcn-dialog-actions">
            <slot name="actions" />
          </div>
        </DialogContent>
      </div>
    </DialogPortal>
  </DialogRoot>
</template>
export { default as TcnDialog } from './TcnDialog.vue';

Last updated on July 24, 2026

Was this page helpful?