Components · Feedback
Modal
A focused, blocking dialog in two types — a Confirmation (yes/no decision) and an Action (a task surface) — rendered through the shared Modal.
Examples
<!-- Paste-and-run: save as .html and open in a browser. No build step, no bundler.
Modal is a COMPOSITE (antd, no framework-free element), so its HTML form is a
CDN-React page — React + antd load from a CDN (esm.sh) and mount on open. It
consumes the SAME shared modalTheme / modalWidth / modalStyles the React/Vue
wrappers use, so it renders the DS V3 look (white content, radius 8, ink title)
and stays inside the viewport. Keeps antd's built-in motion.
Shows the TWO modal types — a Confirmation (simple) and an Action (complexity) —
each TRIGGERED by a button, both controlled <Modal>s (never static Modal.confirm).
No JSX here (JSX needs a compiler) — we use React.createElement via the `h` alias. -->
<div id="root"></div>
<script type="module">
import React from 'https://esm.sh/react@18';
import { createRoot } from 'https://esm.sh/react-dom@18/client';
import { ConfigProvider, Modal, Button } from 'https://esm.sh/antd@6?deps=react@18,react-dom@18';
import { modalTheme, modalStyles, modalWidth } from 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/modal-theme.js';
import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/icons.js'; // registers <aha-icon>
const h = React.createElement;
const { useState } = React;
// External = an absolute URL whose host isn't ahaslides.com — only then show the ↗ glyph + a new tab.
const isExternalUrl = (href) =>
/^https?:\/\//i.test(href) && !/(^|\.)ahaslides\.com$/i.test(new URL(href).hostname);
const learnMore = 'https://help.example.com/reuse-themes'; // external → shows the glyph
const external = isExternalUrl(learnMore);
// Footer: a "Learn more" link (navigation → a DS link, --aha-text-link + shared <aha-icon>, not a
// Button) on the left, Cancel + Apply on the right. The glyph shows ONLY when the link leaves AhaSlides.
const footer = (danger, close) => h('div',
{ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
h('a', { href: learnMore, target: external ? '_blank' : undefined, rel: external ? 'noreferrer' : undefined,
style: { display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--aha-text-link)', textDecoration: 'none' } },
'Learn more ', external && h('aha-icon', { name: 'system-arrow-square-out', size: '14' })),
h('span', { style: { display: 'inline-flex', gap: 8 } },
h(Button, { onClick: close }, 'Cancel'),
h(Button, { type: 'primary', danger }, 'Apply')));
function App() {
const [confirm, setConfirm] = useState(false);
const [action, setAction] = useState(false);
return h(ConfigProvider, { theme: modalTheme },
h(Button, { onClick: () => setConfirm(true) }, 'Delete team…'),
h(Button, { type: 'primary', onClick: () => setAction(true) }, 'Share course…'),
// Confirmation modal — always `simple` (504px · 75vh); danger tints Apply.
h(Modal, {
open: confirm, title: 'Delete this team?',
width: modalWidth('simple'), styles: modalStyles('simple'), centered: true,
footer: footer(true, () => setConfirm(false)),
onCancel: () => setConfirm(false),
}, h('p', null, "This removes the team and everyone's access. This can't be undone.")),
// Action modal — a longer task; `complexity` (720px · 80vh), body scrolls when tall.
h(Modal, {
open: action, title: 'Share course',
width: modalWidth('complexity'), styles: modalStyles('complexity'), centered: true,
footer: footer(false, () => setAction(false)),
onCancel: () => setAction(false),
})
);
}
// Same shared theme + helpers the React/Vue wrappers pass to ConfigProvider.
createRoot(document.getElementById('root')).render(h(App));
</script>
import { useState } from 'react';
import { ConfigProvider, Modal, Button } from 'antd'; // antd v6
import { modalTheme, modalStyles, modalWidth } from '@ahaslides-product/design/modal-theme';
import '@ahaslides-product/design/icons'; // registers <aha-icon> for the Learn-more glyph
// External = an absolute URL whose host isn't ahaslides.com — only then does a link show the
// external-link glyph and open in a new tab. A relative / *.ahaslides.com link stays in-product.
const isExternalUrl = (href) =>
/^https?:\/\//i.test(href) && !/(^|\.)ahaslides\.com$/i.test(new URL(href).hostname);
// The DS has TWO modal types, both controlled <Modal>s (never the imperative confirm dialog):
// • Confirmation — a focused yes/no with an optional status icon + Learn-more link. `simple` size.
// • Action — a task surface (form / picker / editor). Sizes: simple · complexity · rich.
// A button TRIGGERS each; width={modalWidth(size)} keeps it near-full-width on mobile (min(px,
// 100vw−32)) and styles={modalStyles(size)} caps the height, the body scrolling past the cap.
function ModalDemo() {
const [confirm, setConfirm] = useState(false);
const [action, setAction] = useState(false);
const learnMore = 'https://help.example.com/reuse-themes'; // external → shows the ↗ glyph
const external = isExternalUrl(learnMore);
const footer = (danger) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{/* "Learn more" is navigation → a DS link (--aha-text-link + shared <aha-icon>), not a Button.
The external-link glyph shows ONLY when the target leaves AhaSlides. */}
<a href={learnMore} target={external ? '_blank' : undefined} rel={external ? 'noreferrer' : undefined}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--aha-text-link)', textDecoration: 'none' }}>
Learn more {external && <aha-icon name="system-arrow-square-out" size="14" />}
</a>
<span style={{ display: 'inline-flex', gap: 8 }}>
<Button onClick={() => { setConfirm(false); setAction(false); }}>Cancel</Button>
<Button type="primary" danger={danger}>Apply</Button>
</span>
</div>
);
return (
<ConfigProvider theme={modalTheme}>
<Button onClick={() => setConfirm(true)}>Delete team…</Button>
<Button type="primary" onClick={() => setAction(true)}>Share course…</Button>
{/* Confirmation modal — always `simple`; the danger context tints Apply. */}
<Modal
open={confirm}
title="Delete this team?"
width={modalWidth('simple')}
styles={modalStyles('simple')}
centered
footer={footer(true)}
onCancel={() => setConfirm(false)}
>
<p>This removes the team and everyone's access. This can't be undone.</p>
</Modal>
{/* Action modal — a longer task; `complexity` (720px · 80vh), body scrolls when tall. */}
<Modal
open={action}
title="Share course"
width={modalWidth('complexity')}
styles={modalStyles('complexity')}
centered
footer={footer(false)}
onCancel={() => setAction(false)}
>
{/* …form fields / picker / editor… */}
</Modal>
</ConfigProvider>
);
}
// One shared modalTheme → the DS V3 look; keeps antd's built-in zoom motion.
<script setup>
import { ref, h } from 'vue';
import { ConfigProvider, Modal, Button } from 'ant-design-vue'; // ant-design-vue v4
import { modalTheme, modalStyles, modalWidth } from '@ahaslides-product/design/modal-theme';
import '@ahaslides-product/design/icons'; // registers <aha-icon> for the Learn-more glyph
// External = an absolute URL whose host isn't ahaslides.com — only then show the ↗ glyph + a new tab.
const isExternalUrl = (href) =>
/^https?:\/\//i.test(href) && !/(^|\.)ahaslides\.com$/i.test(new URL(href).hostname);
const learnMore = 'https://help.example.com/reuse-themes'; // external → shows the glyph
const external = isExternalUrl(learnMore);
// The DS has TWO modal types, both controlled <a-modal>s (never the imperative confirm dialog):
// • Confirmation — a focused yes/no with an optional status icon + Learn-more link. `simple` size.
// • Action — a task surface (form / picker / editor). Sizes: simple · complexity · rich.
// A button TRIGGERS each; :width="modalWidth(size)" keeps it near-full-width on mobile (min(px,
// 100vw−32)) and :styles="modalStyles(size)" caps the height, the body scrolling past the cap.
const confirm = ref(false);
const action = ref(false);
// Footer render-fn: a Learn-more link (navigation → DS link, not a Button) on the left,
// Cancel + Apply on the right.
const footer = (danger, close) => h('div',
{ style: 'display:flex;align-items:center;justify-content:space-between' }, [
h('a', { href: learnMore, target: external ? '_blank' : undefined, rel: external ? 'noreferrer' : undefined,
style: 'display:inline-flex;align-items:center;gap:6px;color:var(--aha-text-link);text-decoration:none' }, [
'Learn more ', external ? h('aha-icon', { name: 'system-arrow-square-out', size: '14' }) : null,
]),
h('span', { style: 'display:inline-flex;gap:8px' }, [
h(Button, { onClick: close }, () => 'Cancel'),
h(Button, { type: 'primary', danger }, () => 'Apply'),
]),
]);
</script>
<template>
<a-config-provider :theme="modalTheme">
<a-button @click="confirm = true">Delete team…</a-button>
<a-button type="primary" @click="action = true">Share course…</a-button>
<!-- Confirmation modal — always `simple`; the danger context tints Apply. -->
<a-modal
v-model:open="confirm"
title="Delete this team?"
:width="modalWidth('simple')"
:styles="modalStyles('simple')"
centered
:footer="footer(true, () => (confirm = false))"
>
<p>This removes the team and everyone's access. This can't be undone.</p>
</a-modal>
<!-- Action modal — a longer task; `complexity` (720px · 80vh), body scrolls when tall. -->
<a-modal
v-model:open="action"
title="Share course"
:width="modalWidth('complexity')"
:styles="modalStyles('complexity')"
centered
:footer="footer(false, () => (action = false))"
>
<!-- …form fields / picker / editor… -->
</a-modal>
</a-config-provider>
</template>
<!-- The SAME modalTheme + modalWidth/modalStyles as React → one DS V3 look across both vendor libraries. -->
// @ahaslides-product/design/modal-theme — declared ONCE, consumed by both tiers.
import { modalTheme, modalStyles, modalWidth } from '@ahaslides-product/design/modal-theme';
export const modalTheme = {
token: {
colorPrimary: '#6A1EBB',
borderRadius: 8,
colorText: '#1A1A1A',
colorBgElevated: '#FFFFFF',
fontFamily: 'var(--aha-font-product, "Plus Jakarta Sans", sans-serif)',
},
components: {
Modal: {
contentBg: '#FFFFFF',
headerBg: '#FFFFFF',
titleColor: '#1A1A1A',
titleFontSize: 18,
borderRadiusLG: 8,
},
},
};
// DS V3 size policy — a modal never grows bigger than the screen and stays usable on mobile.
// Three size tiers (Action-modal use cases; a Confirmation modal uses `simple`), each a target
// px width. WIDTH via the `width` prop: modalWidth(size) → min(<target px>, calc(100vw − 32px)),
// so it's the px width on desktop and near-full-width on a phone (a fixed vw would collapse to
// ~135px). HEIGHT via styles={modalStyles(size)} — the body scrolls, title + footer stay pinned.
export const modalMaxHeight = { simple: '75vh', complexity: '80vh', rich: '90vh' };
const MODAL_TARGET_W = { simple: 504, complexity: 720, rich: 1280 };
const MODAL_GUTTER = 32; // 16px each side kept on small screens
// Per-tier vw on a 1440 design — reference only; modalWidth() does NOT use it (not mobile-safe).
export const modalMaxWidth = { simple: '35vw', complexity: '50vw', rich: '90vw' };
export const modalWidth = (size = 'simple') => `min(${MODAL_TARGET_W[size]}px, calc(100vw - ${MODAL_GUTTER}px))`;
export const modalStyles = (size = 'simple') => ({
// `container` = the antd v6 dialog box (.ant-modal-container), NOT `content` (dead key in v6).
container: { display: 'flex', flexDirection: 'column', maxHeight: modalMaxHeight[size] },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
});
API
| Prop | Type | Default | Notes |
|---|---|---|---|
open | boolean | false | Controls visibility |
title | string | — | Dialog heading |
onOk | () => void | — | Confirm handler |
onCancel | () => void | — | Dismiss handler |
confirmLoading | boolean | false | Spinner on the confirm button |
width | string | modalWidth('simple') | Dialog width — pass modalWidth(size): min(target px, calc(100vw − 32px)) so it's the px width on desktop and near-full-width on mobile. Targets: simple 504 / complexity 720 / rich 1280 |
styles | object | modalStyles('simple') | Height cap + body scroll for the size — modalStyles(size) |
footer | ReactNode | — | DS footer: Learn-more link (left, a --aha-text-link anchor — external-link glyph only when it leaves AhaSlides) + Cancel + Apply (right) |
centered | boolean | false | Vertically centre the dialog in the viewport |
closable | boolean | true | Show the top-right close (X) |
mask | boolean | true | Render the dimming overlay behind the dialog |
Install
# .npmrc — once: point the @ahaslides-product scope at GitHub Packages
@ahaslides-product:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} # a GitHub token with read:packages
npm i @ahaslides-product/design
import '@ahaslides-product/design/tokens.css'; // once, at the app root
import '@ahaslides-product/design/modal-theme'; // registers <modal>
Agent feed for this component (absolute, fetchable anywhere): modal.agent.json · modal.md · modal.llms.txt
When to use
When to use
- Modal — the user must confirm or complete a focused task before continuing
- Drawer — a longer edit form or detail panel that keeps page context
- Popconfirm — a lightweight yes/no on a single control
Two types. A CONFIRMATION modal is a focused yes/no — one of five contexts (default · confirm · warning · info · danger) puts a status icon left of the title, with a short line of copy, a Learn-more link, and Cancel + Apply; it's always `simple` size. An ACTION modal is a task surface (form, picker, editor) at one of three sizes — simple / complexity / rich — with an optional header/footer divider. Both are controlled `<Modal>`s, never the static Modal.confirm(). Every modal opens as a real overlay: it portals to `<body>` with an always-on mask, locks page scroll while open, and closes on a mask click / Esc / the ✕ — EXCEPT a destructive confirmation, which overrides `mask={{ closable: false }}` so a stray backdrop click can't trigger the action (Esc + ✕ only). Name the primary button the action ('Delete team', not 'OK'). A modal must never grow bigger than the screen in either axis: set `width={modalWidth(size)}` (resolves to `min(<target px>, calc(100vw − 32px))` — the px width on desktop, near-full-width on mobile with a 16px gutter; antd centres the dialog by its width, so the cap lives on the prop, not inner styles) and `styles={modalStyles(size)}` (height `auto` up to the cap, then the body scrolls while title + footer stay pinned). Anything bigger than `rich` belongs in its own page.
Surfaces
editor dashboard settings
Spec
Content white surface · radius 8 · Title ink #1A1A1A · 18 · SemiBold · Footer brand primary confirm + secondary cancel · Overlay always-on mask (ink rgba(26,26,46,.7)) · portals to body · locks page scroll · click mask / Esc / ✕ to close (destructive: mask not closable) · Motion antd's built-in zoom enter/leave (kept) · Types Confirmation (status icon + copy) · Action (task surface) · Confirmation contexts: default · confirm · warning · info · danger (status icon left of title) · Action sizes simple 504 · complexity 720 · rich 1280 (px width) × height 75/80/90vh · divider on/off · Viewport cap width={modalWidth(size)} = min(px, calc(100vw − 32px)) — px on desktop, near-full-width on mobile; styles={modalStyles(size)} caps height, body scrolls past it