Warm Editorial — overlay
Built on the native dialog element so focus trapping and the top layer come from the platform. Warm scrim, 28px corners, scroll lock, and focus restored to the trigger on close.
Paste this into Claude, Cursor or Copilot — or point the agent at
https://ui-deploy.com/r/warm-editorial/modal/prompt.md
# Dialog Modal — Warm Editorial
Paste this whole file into Claude, Cursor or Copilot.
---
## What to build
A centred dialog on a warm scrim: eyebrow, heading, lead, one field, two pill
buttons, and a round close button in the top right. 28px corners, `--bg2` surface.
**Build it on the native `<dialog>` element.** Focus trapping, the top layer and
Escape-to-close come from the platform; reimplementing them by hand is how modals
end up inaccessible.
---
## Tokens
```css
:root{
--bg2:#F6F3ED; --bg3:#E4DED2; --border:#DAD3C6; --border2:#C6BDAC;
--text:#22201D; --text2:#5C574F; --text3:#8C857A;
--accent:#A38A6A; --accent-text:#7D6644; --accent-s:rgba(163,138,106,.12);
--radius:28px; --radius-sm:12px; --radius-pill:999px;
--shadow-md:0 18px 48px -24px rgba(34,32,29,.22);
}
[data-theme="dark"]{
--bg2:#1E1A17; --bg3:#262119; --border:#2E2822; --border2:#40382F;
--text:#EDE7DD; --text2:#A79E92; --text3:#8A8177;
--accent:#B39B7C; --accent-text:#C0A888; --accent-s:rgba(179,155,124,.13);
--shadow-md:0 18px 48px -24px rgba(0,0,0,.7);
}
```
---
## Rules — these are not preferences
1. **`showModal()`, never `show()`.** Only `showModal` gives the focus trap, the
backdrop and the top layer. `show()` looks similar and is not the same thing.
2. **Restore focus to the trigger on close.** Listen on the `close` event rather
than the close button — `close` also fires for Escape and for a backdrop click,
so one handler covers every path. Without this, keyboard users are dumped at
the top of the document.
3. **The scrim is warm**, `rgba(21,18,16,.55)`. A neutral black scrim over this
palette reads as grey and kills the warmth of everything behind it.
4. **Lock body scroll while open**, and release it in the same `close` handler.
5. **Link the heading with `aria-labelledby`** pointing at the title's id.
6. **A backdrop click is `e.target === dialog`** — the dialog element fills the top
layer, so a click that lands on it rather than a child came from the backdrop.
7. **`margin:auto`** centres a `<dialog>` in both axes. Do not reach for
`position:fixed` and transforms.
8. Buttons are pills. Inputs are 12px soft corners, not pills. Icons inline SVG.
9. Below 560px the buttons stack full width.
---
## Adapting it
- **Confirm dialog** — drop the field, keep both buttons, and give the destructive
action `--accent` fill with `--on-accent` text. Never a red band; this palette
has no red.
- **Long content** — put `max-height:85dvh; overflow-y:auto` on `.we-dialog-body`,
not on the dialog, so the close button stays pinned.
- **A form that submits** — keep `method="dialog"` only on the close form. A real
submit needs its own `<form>` with an action.
---
## Reference implementation
See `code.html` — 22 lines of dependency-free JS.
Paste this whole file into Claude, Cursor or Copilot.
A centred dialog on a warm scrim: eyebrow, heading, lead, one field, two pill buttons, and a round close button in the top right. 28px corners, --bg2 surface.
Build it on the native <dialog> element. Focus trapping, the top layer and Escape-to-close come from the platform; reimplementing them by hand is how modals end up inaccessible.
:root{
--bg2:#F6F3ED; --bg3:#E4DED2; --border:#DAD3C6; --border2:#C6BDAC;
--text:#22201D; --text2:#5C574F; --text3:#8C857A;
--accent:#A38A6A; --accent-text:#7D6644; --accent-s:rgba(163,138,106,.12);
--radius:28px; --radius-sm:12px; --radius-pill:999px;
--shadow-md:0 18px 48px -24px rgba(34,32,29,.22);
}
[data-theme="dark"]{
--bg2:#1E1A17; --bg3:#262119; --border:#2E2822; --border2:#40382F;
--text:#EDE7DD; --text2:#A79E92; --text3:#8A8177;
--accent:#B39B7C; --accent-text:#C0A888; --accent-s:rgba(179,155,124,.13);
--shadow-md:0 18px 48px -24px rgba(0,0,0,.7);
}
showModal(), never show(). Only showModal gives the focus trap, the backdrop and the top layer. show() looks similar and is not the same thing.close event rather than the close button — close also fires for Escape and for a backdrop click, so one handler covers every path. Without this, keyboard users are dumped at the top of the document.rgba(21,18,16,.55). A neutral black scrim over this palette reads as grey and kills the warmth of everything behind it.close handler.aria-labelledby pointing at the title's id.e.target === dialog — the dialog element fills the top layer, so a click that lands on it rather than a child came from the backdrop.margin:auto centres a <dialog> in both axes. Do not reach for position:fixed and transforms. action --accent fill with --on-accent text. Never a red band; this palette has no red.
max-height:85dvh; overflow-y:auto on .we-dialog-body,not on the dialog, so the close button stays pinned.
method="dialog" only on the close form. A real submit needs its own <form> with an action.
See code.html — 22 lines of dependency-free JS.
<!-- Warm Editorial — Dialog modal
Requires tokens.css. Built on <dialog> so focus trapping, Escape and the
top layer come from the platform instead of being reimplemented. -->
<div class="we-modal-demo">
<button class="we-modal-open" type="button" data-open-modal="we-dialog">
Request a callback
</button>
</div>
<dialog class="we-dialog" id="we-dialog" aria-labelledby="we-dialog-title">
<form method="dialog" class="we-dialog-x-form">
<button class="we-dialog-x" value="close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12"/><path d="M18 6L6 18"/>
</svg>
</button>
</form>
<div class="we-dialog-body">
<p class="we-dialog-eyebrow"><span aria-hidden="true"></span>Callback</p>
<h2 class="we-dialog-title" id="we-dialog-title">Leave a number, we will call.</h2>
<p class="we-dialog-lead">
Weekdays between nine and six. No queue, no script — you get whoever is on
the floor that day.
</p>
<div class="we-dialog-field">
<label class="we-dialog-label" for="we-dialog-tel">Phone number</label>
<input class="we-dialog-input" id="we-dialog-tel" type="tel" autocomplete="tel"
placeholder="+44 1234 567 890">
</div>
<div class="we-dialog-actions">
<button class="we-dialog-btn we-dialog-btn-primary" type="button">Request the call</button>
<button class="we-dialog-btn we-dialog-btn-ghost" type="button" data-close-modal>Not now</button>
</div>
</div>
</dialog>
<style>
.we-modal-demo{display:flex;justify-content:center;padding:40px 20px}
.we-modal-open{
display:inline-flex;align-items:center;height:48px;padding:0 22px;
border:0;border-radius:var(--radius-pill);
background:var(--text);color:var(--bg);
font-family:var(--font-body);font-size:14px;font-weight:500;cursor:pointer;
}
.we-modal-open:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.we-dialog{
width:min(100% - 32px, 460px);
padding:0;border:1px solid var(--border);border-radius:var(--radius);
background:var(--bg2);color:var(--text);
box-shadow:var(--shadow-md);
/* margin:auto centres it in the top layer in both axes. */
margin:auto;
}
/* Warm scrim, not neutral black — a cold scrim over this palette reads grey. */
.we-dialog::backdrop{background:rgba(21,18,16,.55);backdrop-filter:blur(3px)}
.we-dialog-x-form{margin:0;position:absolute;top:16px;right:16px}
.we-dialog-x{
display:grid;place-items:center;width:36px;height:36px;padding:0;
border:1px solid var(--border);border-radius:50%;
background:var(--bg3);color:var(--text2);cursor:pointer;
transition:color .2s var(--ease), background .2s var(--ease);
}
.we-dialog-x svg{width:17px;height:17px}
.we-dialog-x:hover{color:var(--text);background:var(--border)}
.we-dialog-body{padding:32px 30px 30px}
.we-dialog-eyebrow{
display:flex;align-items:center;gap:7px;margin:0 0 14px;
font-family:var(--font-display);font-size:11.5px;font-weight:600;
letter-spacing:.16em;text-transform:uppercase;color:var(--accent-text);
}
.we-dialog-eyebrow span{width:5px;height:5px;border-radius:50%;background:var(--accent);flex:0 0 5px}
.we-dialog-title{
font-family:var(--font-display);font-size:clamp(22px,3.4vw,30px);font-weight:600;
letter-spacing:-.02em;line-height:1.1;color:var(--text);margin:0 0 12px;
}
.we-dialog-lead{margin:0 0 24px;font-size:14.5px;line-height:1.7;color:var(--text2)}
.we-dialog-field{display:flex;flex-direction:column;margin-bottom:24px}
.we-dialog-label{margin-bottom:8px;font-size:13px;font-weight:500;color:var(--text2)}
.we-dialog-input{
width:100%;min-width:0;height:48px;padding:0 15px;
background:var(--bg3);border:1px solid var(--border2);border-radius:var(--radius-sm);
font-family:var(--font-body);font-size:15px;color:var(--text);
}
.we-dialog-input::placeholder{color:var(--text3)}
.we-dialog-input:focus{
outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-s);
}
.we-dialog-actions{display:flex;flex-wrap:wrap;gap:10px}
.we-dialog-btn{
display:inline-flex;align-items:center;justify-content:center;
height:48px;padding:0 22px;border:1px solid transparent;
border-radius:var(--radius-pill);
font-family:var(--font-body);font-size:14px;font-weight:500;cursor:pointer;
transition:opacity .2s var(--ease), background .2s var(--ease);
}
.we-dialog-btn-primary{background:var(--text);color:var(--bg)}
.we-dialog-btn-primary:hover{opacity:.85}
.we-dialog-btn-ghost{background:transparent;border-color:var(--border2);color:var(--text2)}
.we-dialog-btn-ghost:hover{background:var(--bg3);color:var(--text)}
.we-dialog button:focus-visible,
.we-dialog input:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.we-dialog-input:focus-visible{outline:none}
@media (max-width:560px){
.we-dialog-body{padding:28px 22px 24px}
.we-dialog-actions{flex-direction:column}
.we-dialog-btn{width:100%}
}
@media (prefers-reduced-motion:reduce){.we-dialog *{transition:none}}
</style>
<script>
(function () {
var lastTrigger = null;
document.querySelectorAll('[data-open-modal]').forEach(function (btn) {
btn.addEventListener('click', function () {
var dlg = document.getElementById(btn.dataset.openModal);
if (!dlg) return;
lastTrigger = btn;
// showModal, not show: it is what gives the focus trap and the backdrop.
dlg.showModal();
document.body.style.overflow = 'hidden';
});
});
document.querySelectorAll('dialog').forEach(function (dlg) {
dlg.querySelectorAll('[data-close-modal]').forEach(function (b) {
b.addEventListener('click', function () { dlg.close(); });
});
// Click on the backdrop closes. The dialog element itself fills the top
// layer, so a click landing on it and not on a child is a backdrop click.
dlg.addEventListener('click', function (e) {
if (e.target === dlg) dlg.close();
});
// close fires for Escape too, so restoring focus here covers every path.
dlg.addEventListener('close', function () {
document.body.style.overflow = '';
if (lastTrigger) { lastTrigger.focus(); lastTrigger = null; }
});
});
})();
</script>
Needs tokens.css on the page.