Accordion expandWeb · App
Target
Implement the "Modal scale-in" effect in vanilla HTML/CSS/JS. Context: A dialog fades and scales up from 96% with a slight rise while the backdrop fades in; closing reverses faster. Trigger: the element is pressed (pointerdown → pointerup). Timeline: time-based (duration + easing). Motion tokens (normal): - opacity (backdrop): 0 → 1 - opacity (dialog): 0 → 1 - scale (dialog): 0.96 → 1 - translateY (dialog): 8px → 0 - duration: 220ms open · 160ms close - easing: cubic-bezier(0.16, 1, 0.3, 1) open · ease-in close Motion tokens (prefers-reduced-motion): - opacity (backdrop): 0 → 1 - opacity (dialog): 0 → 1 - duration: 150ms - easing: ease-out Technique: prefer pure CSS transitions/animations; use JS only to toggle classes. Acceptance: - animate only transform and opacity unless the spec lists other properties - no layout shift; content is visible without the animation - when prefers-reduced-motion is set, use the reduced tokens only - close is faster than open - focus moves into the dialog on open and returns to the trigger on close
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-modal-scale-in" data-fx-root>
<div class="fx-page">
<button type="button" class="fx-open">Delete project</button>
<div class="fx-backdrop" aria-hidden="true"></div>
<div class="fx-dialog" role="dialog" aria-modal="true" aria-labelledby="fx-msi-t" aria-hidden="true">
<h3 id="fx-msi-t" class="fx-t">Delete this project?</h3>
<p class="fx-d">This can't be undone.</p>
<div class="fx-actions"><button type="button" class="fx-cancel">Cancel</button><button type="button" class="fx-danger">Delete</button></div>
</div>
</div>
</div>
demo.css
.fx-modal-scale-in { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.fx-modal-scale-in .fx-page { position: relative; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.fx-modal-scale-in .fx-open { height: 40px; padding: 0 18px; border: 1px solid var(--fx-muted, #e4e4e7); border-radius: 10px; background: #fff; font: 500 14px/1 Inter, system-ui, sans-serif; color: var(--fx-ink, #18181b); cursor: pointer; }
.fx-modal-scale-in .fx-backdrop { position: absolute; inset: 0; background: rgba(24,24,27,.45); opacity: 0; pointer-events: none; transition: opacity 160ms ease-in; }
.fx-modal-scale-in .fx-dialog { position: absolute; width: 68%; max-width: 300px; padding: 18px; border-radius: 14px; background: #fff; box-shadow: 0 24px 60px rgba(0,0,0,.25); opacity: 0; transform: translateY(8px) scale(.96); pointer-events: none; transition: opacity 160ms ease-in, transform 160ms ease-in; font-family: Inter, system-ui, sans-serif; }
.fx-modal-scale-in .fx-t { margin: 0 0 6px; font-size: 15px; font-weight: 600; color: var(--fx-ink, #18181b); }
.fx-modal-scale-in .fx-d { margin: 0 0 14px; font-size: 13px; color: #71717a; }
.fx-modal-scale-in .fx-actions { display: flex; justify-content: flex-end; gap: 8px; }
.fx-modal-scale-in .fx-actions button { height: 34px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--fx-muted, #e4e4e7); background: #fff; font: 500 13px/1 Inter, system-ui, sans-serif; cursor: pointer; }
.fx-modal-scale-in .fx-danger { background: #dc2626; border-color: #dc2626; color: #fff; }
.fx-modal-scale-in.is-open .fx-backdrop { opacity: 1; pointer-events: auto; transition-duration: 220ms; transition-timing-function: ease-out; }
.fx-modal-scale-in.is-open .fx-dialog { opacity: 1; transform: none; pointer-events: auto; transition: opacity 220ms cubic-bezier(.16, 1, .3, 1), transform 220ms cubic-bezier(.16, 1, .3, 1); }
@media (prefers-reduced-motion: reduce) { .fx-modal-scale-in .fx-dialog { transform: none; transition: opacity 150ms ease-out; } }
demo.js
export default function mount(root) {
const open = root.querySelector('.fx-open'), dialog = root.querySelector('.fx-dialog'), cancel = root.querySelector('.fx-cancel'), danger = root.querySelector('.fx-danger'), backdrop = root.querySelector('.fx-backdrop');
const set = (o) => { root.classList.toggle('is-open', o); dialog.setAttribute('aria-hidden', String(!o)); if (o) cancel.focus({ preventScroll: true }); else if (root.contains(document.activeElement)) open.focus({ preventScroll: true }); };
open.addEventListener('click', () => set(true)); cancel.addEventListener('click', () => set(false)); danger.addEventListener('click', () => set(false)); backdrop.addEventListener('click', () => set(false));
let t1, t2;
return {
play() { clearTimeout(t1); clearTimeout(t2); root.classList.remove('is-open'); t1 = setTimeout(() => root.classList.add('is-open'), 450); t2 = setTimeout(() => root.classList.remove('is-open'), 2000); },
stop() { clearTimeout(t1); clearTimeout(t2); root.classList.remove('is-open'); dialog.setAttribute('aria-hidden', 'true'); },
};
}
Inspired by developer.chrome.com — Chrome: entry/exit animations with @starting-style. The preview is an original re-creation; the effect name belongs to its author.
Open with an expo-out curve (fast start, soft landing), close with a short ease-in: users want the dialog gone immediately. In production use `<dialog>` + `@starting-style` so the same keyframes run on `showModal()`; the class toggle here is the universal fallback.
Added 2026-11-09 · Report an issue