Modal
Dialog with built-in portal, focus trap, scroll lock, tone variants, async confirm, draggable, resizable, imperative service, stacked z-index.
Basic usage
v-model:open (Vue) or open + onOpenChange (React) for two-way binding. The component handles portal-to-body, focus trap, body scroll lock, ESC close, overlay click, and fade + scale transitions internally.
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const open = ref(false);
</script>
<template>
<CfButton @click="open = true">打开 Modal</CfButton>
<CfModal v-model:open="open" title="确认操作">
<p style="margin: 0;">这是一个最简弹窗。点击遮罩 / Esc / × 都能关闭。</p>
<template #footer>
<CfButton variant="ghost" @click="open = false">取消</CfButton>
<CfButton @click="open = false">确定</CfButton>
</template>
</CfModal>
</template> <script setup>
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const open = ref(false);
</script>
<template>
<CfButton @click="open = true">打开 Modal</CfButton>
<CfModal v-model:open="open" title="确认操作">
<p style="margin: 0;">这是一个最简弹窗。点击遮罩 / Esc / × 都能关闭。</p>
<template #footer>
<CfButton variant="ghost" @click="open = false">取消</CfButton>
<CfButton @click="open = false">确定</CfButton>
</template>
</CfModal>
</template> Sizes
size controls the max width. full fills nearly the entire viewport. width / min-height accept custom pixel values.
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const sm = ref(false);
const md = ref(false);
const lg = ref(false);
const xl = ref(false);
const full = ref(false);
</script>
<template>
<div class="demo-row">
<CfButton variant="tertiary" @click="sm = true">sm · 360px</CfButton>
<CfButton variant="tertiary" @click="md = true">md · 480px</CfButton>
<CfButton variant="tertiary" @click="lg = true">lg · 640px</CfButton>
<CfButton variant="tertiary" @click="xl = true">xl · 800px</CfButton>
<CfButton variant="tertiary" @click="full = true">full · 几乎铺满</CfButton>
</div>
<CfModal v-model:open="sm" size="sm" title="size = sm">
<p style="margin: 0;">最大宽度 360px,适合精简的确认弹窗。</p>
</CfModal>
<CfModal v-model:open="md" size="md" title="size = md(默认)">
<p style="margin: 0;">最大宽度 480px,是大多数表单的合理尺寸。</p>
</CfModal>
<CfModal v-model:open="lg" size="lg" title="size = lg">
<p style="margin: 0;">最大宽度 640px,适合内容较多的弹窗。</p>
</CfModal>
<CfModal v-model:open="xl" size="xl" title="size = xl">
<p style="margin: 0;">最大宽度 800px,适合复杂表单或预览面板。</p>
</CfModal>
<CfModal v-model:open="full" size="full" title="size = full">
<p style="margin: 0;">几乎铺满视口(保留 1.5rem 外边距),适合设置面板或全屏预览。</p>
</CfModal>
</template> <script setup>
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const sm = ref(false);
const md = ref(false);
const lg = ref(false);
const xl = ref(false);
const full = ref(false);
</script>
<template>
<div class="demo-row">
<CfButton variant="tertiary" @click="sm = true">sm · 360px</CfButton>
<CfButton variant="tertiary" @click="md = true">md · 480px</CfButton>
<CfButton variant="tertiary" @click="lg = true">lg · 640px</CfButton>
<CfButton variant="tertiary" @click="xl = true">xl · 800px</CfButton>
<CfButton variant="tertiary" @click="full = true">full · 几乎铺满</CfButton>
</div>
<CfModal v-model:open="sm" size="sm" title="size = sm">
<p style="margin: 0;">最大宽度 360px,适合精简的确认弹窗。</p>
</CfModal>
<CfModal v-model:open="md" size="md" title="size = md(默认)">
<p style="margin: 0;">最大宽度 480px,是大多数表单的合理尺寸。</p>
</CfModal>
<CfModal v-model:open="lg" size="lg" title="size = lg">
<p style="margin: 0;">最大宽度 640px,适合内容较多的弹窗。</p>
</CfModal>
<CfModal v-model:open="xl" size="xl" title="size = xl">
<p style="margin: 0;">最大宽度 800px,适合复杂表单或预览面板。</p>
</CfModal>
<CfModal v-model:open="full" size="full" title="size = full">
<p style="margin: 0;">几乎铺满视口(保留 1.5rem 外边距),适合设置面板或全屏预览。</p>
</CfModal>
</template> Close behavior
By default any close path works: overlay click, ESC, top-right ×. Disable individually with closeOnOverlay / closeOnEsc / showClose. While async (onBeforeOk in flight) every close path is blocked automatically.
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const a = ref(false);
const b = ref(false);
</script>
<template>
<div class="demo-row">
<CfButton variant="tertiary" @click="a = true">禁用遮罩关闭</CfButton>
<CfButton variant="tertiary" @click="b = true">隐藏 × 按钮</CfButton>
</div>
<CfModal v-model:open="a" title="只能从底部关闭" :close-on-overlay="false">
<p style="margin: 0;">点遮罩不会关闭,只能用 Esc 或下面的按钮。</p>
<template #footer>
<CfButton @click="a = false">我知道了</CfButton>
</template>
</CfModal>
<CfModal v-model:open="b" title="无右上角 ×" :show-close="false">
<p style="margin: 0;">这个 Modal 隐藏了右上角的关闭按钮,必须靠 footer 操作或 Esc 关闭。</p>
<template #footer>
<CfButton @click="b = false">关闭</CfButton>
</template>
</CfModal>
</template> <script setup>
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const a = ref(false);
const b = ref(false);
</script>
<template>
<div class="demo-row">
<CfButton variant="tertiary" @click="a = true">禁用遮罩关闭</CfButton>
<CfButton variant="tertiary" @click="b = true">隐藏 × 按钮</CfButton>
</div>
<CfModal v-model:open="a" title="只能从底部关闭" :close-on-overlay="false">
<p style="margin: 0;">点遮罩不会关闭,只能用 Esc 或下面的按钮。</p>
<template #footer>
<CfButton @click="a = false">我知道了</CfButton>
</template>
</CfModal>
<CfModal v-model:open="b" title="无右上角 ×" :show-close="false">
<p style="margin: 0;">这个 Modal 隐藏了右上角的关闭按钮,必须靠 footer 操作或 Esc 关闭。</p>
<template #footer>
<CfButton @click="b = false">关闭</CfButton>
</template>
</CfModal>
</template> Tone variants
tone="info | success | warning | error" adds a circular tone icon plus accent color in the header. tone="error" automatically switches the default OK button to danger red.
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const open = ref<'info' | 'success' | 'warning' | 'error' | null>(null);
function set(t: typeof open.value) { open.value = t; }
</script>
<template>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<CfButton variant="tertiary" @click="set('info')">Info</CfButton>
<CfButton variant="tertiary" @click="set('success')">Success</CfButton>
<CfButton variant="tertiary" @click="set('warning')">Warning</CfButton>
<CfButton variant="danger" @click="set('error')">Error</CfButton>
</div>
<CfModal
:open="open === 'info'"
@update:open="(v) => set(v ? 'info' : null)"
tone="info"
title="新功能上线"
description="表格组件现已支持双向虚拟化与 ResizeObserver 自动测高。"
ok-text="知道了"
/>
<CfModal
:open="open === 'success'"
@update:open="(v) => set(v ? 'success' : null)"
tone="success"
title="部署成功"
description="v0.1.5 已发布到生产环境。"
ok-text="完成"
/>
<CfModal
:open="open === 'warning'"
@update:open="(v) => set(v ? 'warning' : null)"
tone="warning"
title="离开当前页面?"
description="未保存的更改会丢失。"
ok-text="离开"
cancel-text="留下"
/>
<CfModal
:open="open === 'error'"
@update:open="(v) => set(v ? 'error' : null)"
tone="error"
title="删除工作区"
description="工作区内的所有数据将被立即清除,且无法恢复。"
ok-text="确认删除"
cancel-text="取消"
/>
</template> <script setup>
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const open = ref<'info' | 'success' | 'warning' | 'error' | null>(null);
function set(t: typeof open.value) { open.value = t; }
</script>
<template>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<CfButton variant="tertiary" @click="set('info')">Info</CfButton>
<CfButton variant="tertiary" @click="set('success')">Success</CfButton>
<CfButton variant="tertiary" @click="set('warning')">Warning</CfButton>
<CfButton variant="danger" @click="set('error')">Error</CfButton>
</div>
<CfModal
:open="open === 'info'"
@update:open="(v) => set(v ? 'info' : null)"
tone="info"
title="新功能上线"
description="表格组件现已支持双向虚拟化与 ResizeObserver 自动测高。"
ok-text="知道了"
/>
<CfModal
:open="open === 'success'"
@update:open="(v) => set(v ? 'success' : null)"
tone="success"
title="部署成功"
description="v0.1.5 已发布到生产环境。"
ok-text="完成"
/>
<CfModal
:open="open === 'warning'"
@update:open="(v) => set(v ? 'warning' : null)"
tone="warning"
title="离开当前页面?"
description="未保存的更改会丢失。"
ok-text="离开"
cancel-text="留下"
/>
<CfModal
:open="open === 'error'"
@update:open="(v) => set(v ? 'error' : null)"
tone="error"
title="删除工作区"
description="工作区内的所有数据将被立即清除,且无法恢复。"
ok-text="确认删除"
cancel-text="取消"
/>
</template> Built-in OK / Cancel + async confirm
Pass okText / cancelText and the component renders the default footer buttons. onBeforeOk accepts an async function — return false or throw to keep the dialog open and restore the loading state; resolve normally to close and emit @ok.
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal, CfInput } from '@chufix-design/vue';
const open = ref(false);
const phrase = ref('');
async function onBeforeOk() {
// 模拟服务端校验:必须输入 'delete'
await new Promise((r) => setTimeout(r, 800));
if (phrase.value !== 'delete') {
// 阻止关闭
return false;
}
// 真删
await new Promise((r) => setTimeout(r, 600));
}
</script>
<template>
<CfButton variant="danger" @click="open = true">删除项目(异步)</CfButton>
<CfModal
v-model:open="open"
tone="error"
title="确认删除"
description="这是不可撤销的操作。请输入 'delete' 确认。"
:on-before-ok="onBeforeOk"
ok-text="确认删除"
cancel-text="取消"
>
<CfInput v-model="phrase" placeholder="输入 delete 启用确认按钮" />
</CfModal>
</template> <script setup>
import { ref } from 'vue';
import { CfButton, CfModal, CfInput } from '@chufix-design/vue';
const open = ref(false);
const phrase = ref('');
async function onBeforeOk() {
// 模拟服务端校验:必须输入 'delete'
await new Promise((r) => setTimeout(r, 800));
if (phrase.value !== 'delete') {
// 阻止关闭
return false;
}
// 真删
await new Promise((r) => setTimeout(r, 600));
}
</script>
<template>
<CfButton variant="danger" @click="open = true">删除项目(异步)</CfButton>
<CfModal
v-model:open="open"
tone="error"
title="确认删除"
description="这是不可撤销的操作。请输入 'delete' 确认。"
:on-before-ok="onBeforeOk"
ok-text="确认删除"
cancel-text="取消"
>
<CfInput v-model="phrase" placeholder="输入 delete 启用确认按钮" />
</CfModal>
</template> Draggable + resizable
draggable turns the title bar into a drag handle. resizable adds a corner handle for diagonal resizing. Both are PointerEvent-based with zero third-party deps.
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const open = ref(false);
</script>
<template>
<CfButton @click="open = true">打开可拖拽 + 可缩放</CfButton>
<CfModal
v-model:open="open"
title="可移动 / 可缩放"
description="拖标题栏移动;右下角拖把柄缩放最小 280×160。"
draggable
resizable
:centered="false"
ok-text="完成"
cancel-text="取消"
>
<p style="line-height: 1.6;">
在桌面应用风格的工具型 modal 里很常用:用户希望并排看背景内容时移动 modal,或拉大到屏幕大小看长内容。
</p>
</CfModal>
</template> <script setup>
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const open = ref(false);
</script>
<template>
<CfButton @click="open = true">打开可拖拽 + 可缩放</CfButton>
<CfModal
v-model:open="open"
title="可移动 / 可缩放"
description="拖标题栏移动;右下角拖把柄缩放最小 280×160。"
draggable
resizable
:centered="false"
ok-text="完成"
cancel-text="取消"
>
<p style="line-height: 1.6;">
在桌面应用风格的工具型 modal 里很常用:用户希望并排看背景内容时移动 modal,或拉大到屏幕大小看长内容。
</p>
</CfModal>
</template> Imperative service
modal.confirm / danger / alert / info / success / warning / error opens a dialog and returns Promise<boolean> — no state binding, no template nesting. Best for “confirm-then-act” auxiliary flows.
import { modal } from '@chufix-design/vue'; // React: import from '@chufix-design/react'
const ok = await modal.confirm({
title: 'Submit order?',
description: 'This action cannot be undone.',
onOk: async () => {
const r = await api.submit(); // throw or return false to block close
if (!r.ok) return false;
},
});
if (ok) modal.success({ title: 'Submitted' });
<script setup lang="ts">
import { CfButton, modal } from '@chufix-design/vue';
async function onConfirm() {
const ok = await modal.confirm({
title: '提交订单?',
description: '提交后无法撤销。',
onOk: async () => {
await new Promise((r) => setTimeout(r, 800));
// 返回 false 阻止关闭
},
});
if (ok) modal.success({ title: '订单已提交', description: '我们已经发邮件给你确认。' });
}
async function onDanger() {
const ok = await modal.danger({
title: '清空回收站?',
description: '所有 18 个文件将永久删除。',
});
if (ok) modal.info({ title: '已清空' });
}
function onAlert() {
modal.warning({
title: '余额不足',
description: '当前 ¥12.00,本次消费 ¥38.00。请先充值。',
});
}
</script>
<template>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<CfButton variant="primary" @click="onConfirm">异步 confirm</CfButton>
<CfButton variant="danger" @click="onDanger">danger</CfButton>
<CfButton variant="tertiary" @click="onAlert">warning alert</CfButton>
</div>
</template> <script setup>
import { CfButton, modal } from '@chufix-design/vue';
async function onConfirm() {
const ok = await modal.confirm({
title: '提交订单?',
description: '提交后无法撤销。',
onOk: async () => {
await new Promise((r) => setTimeout(r, 800));
// 返回 false 阻止关闭
},
});
if (ok) modal.success({ title: '订单已提交', description: '我们已经发邮件给你确认。' });
}
async function onDanger() {
const ok = await modal.danger({
title: '清空回收站?',
description: '所有 18 个文件将永久删除。',
});
if (ok) modal.info({ title: '已清空' });
}
function onAlert() {
modal.warning({
title: '余额不足',
description: '当前 ¥12.00,本次消费 ¥38.00。请先充值。',
});
}
</script>
<template>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<CfButton variant="primary" @click="onConfirm">异步 confirm</CfButton>
<CfButton variant="danger" @click="onDanger">danger</CfButton>
<CfButton variant="tertiary" @click="onAlert">warning alert</CfButton>
</div>
</template> Service method signatures:
| Method | tone | Default buttons |
|---|---|---|
modal.open(opts) | from opts | from opts |
modal.confirm(opts) | warning | OK + Cancel |
modal.danger(opts) | error | red OK + Cancel |
modal.alert(opts) | info | OK |
modal.info / success / warning / error(opts) | matching tone | OK |
Stacked modals
Each open Modal is pushed onto an internal stack with z-index auto-incremented by 10. ESC only closes the topmost one, and closing pops it from the stack — so you can open a Modal from inside another Modal without thinking about layering.
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const layer1 = ref(false);
const layer2 = ref(false);
</script>
<template>
<CfButton @click="layer1 = true">打开第一层</CfButton>
<CfModal
v-model:open="layer1"
title="第一层"
description="可以在这一层再打开嵌套的对话框。"
ok-text="确定"
cancel-text="取消"
>
<p style="line-height: 1.6; margin-bottom: 12px;">两层 modal 同时存在时,组件维护内部 z-index 栈,每层自动 +10。Esc 只关闭最顶层。</p>
<CfButton variant="tertiary" @click="layer2 = true">打开第二层</CfButton>
<CfModal
v-model:open="layer2"
tone="warning"
title="第二层"
description="这一层是从第一层里弹出来的;遮罩仍然位于第一层之上。"
ok-text="知道了"
/>
</CfModal>
</template> <script setup>
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const layer1 = ref(false);
const layer2 = ref(false);
</script>
<template>
<CfButton @click="layer1 = true">打开第一层</CfButton>
<CfModal
v-model:open="layer1"
title="第一层"
description="可以在这一层再打开嵌套的对话框。"
ok-text="确定"
cancel-text="取消"
>
<p style="line-height: 1.6; margin-bottom: 12px;">两层 modal 同时存在时,组件维护内部 z-index 栈,每层自动 +10。Esc 只关闭最顶层。</p>
<CfButton variant="tertiary" @click="layer2 = true">打开第二层</CfButton>
<CfModal
v-model:open="layer2"
tone="warning"
title="第二层"
description="这一层是从第一层里弹出来的;遮罩仍然位于第一层之上。"
ok-text="知道了"
/>
</CfModal>
</template> API
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Controlled state |
title | string | — | Header title text |
description | string | — | Subtitle below the title |
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | Max-width preset |
tone | 'default' | 'info' | 'success' | 'warning' | 'error' | 'default' | Visual tone + auto icon |
centered | boolean | true | Vertically center vs. 80px from top |
width | number | string | — | Custom width (overrides size) |
minHeight | number | string | — | Custom min height |
closeOnOverlay | boolean | true | Close on overlay click |
closeOnEsc | boolean | true | Close on ESC |
showClose | boolean | true | Top-right × button |
footerAlign | 'start' | 'center' | 'end' | 'space-between' | 'end' | Footer alignment |
draggable | boolean | false | Drag header to move |
resizable | boolean | false | Bottom-right resize handle |
okText / cancelText | string | — | Default button labels; omit to skip default footer |
okVariant | 'primary' | 'danger' | 'secondary' | 'primary' | OK button variant |
onBeforeOk | () => boolean | void | Promise<...> | — | Async hook; false / throw blocks close |
to | string | Element | 'body' | Teleport / Portal target |
zIndex | number | auto-stack | Custom z-index base |
Slots / events
- Vue: named slots
header/ default /footer. Thefooterslot receives{ ok, cancel, loading }for custom button rendering. - React:
header,footer(acceptsReactNodeor({ ok, cancel, loading }) => ReactNode), pluschildren. - Events:
update:open/close/ok/cancel.
反馈与讨论
Modal · Discussion