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