Skip to content

Instantly share code, notes, and snippets.

@dragontheory
Created January 19, 2026 17:26
Show Gist options
  • Select an option

  • Save dragontheory/708d8d077bd3c71b5031b015611347e4 to your computer and use it in GitHub Desktop.

Select an option

Save dragontheory/708d8d077bd3c71b5031b015611347e4 to your computer and use it in GitHub Desktop.
Pure CSS Modals That Fade Gracefully

Image

Tiny, scoped, JS‑free modal pattern that can drop in: it uses <dialog> with :modal, styles its ::backdrop, and animates open/close with @starting-style + transition-behavior: allow-discrete;. State is declarative via a hidden checkbox, but you can swap to :target if you prefer.

<new-modal>
  <!-- state (declarative) -->
  <input type="checkbox" hidden aria-hidden="true">

  <!-- open trigger (anywhere in your UI) -->
  <label role="button">Open modal</label>

  <!-- the modal -->
  <dialog>
    <form method="dialog">
      <header>
        <h2>Title</h2>
      </header>
      <p>Content goes here. No JS required.</p>
      <!-- close trigger -->
      <button>OK</button>
    </form>
  </dialog>
</new-modal>

<style>
/* Base tokens (optional) */
:root { color-scheme: light dark; }

/* Layout host */
new-modal { display: contents; }

/* ---------- Gate: checkbox controls dialog open ---------- */
new-modal > input:checked ~ dialog:not([open])::backdrop,
new-modal > input:checked ~ dialog:not([open]) { /* force-open with discrete transition */
  /* Using the new :modal opening behavior: we “request” open via allow-discrete */
}

/* When label is activated, toggle the checkbox */
new-modal > label { cursor: pointer; }

/* Tie label to checkbox via :has() so click toggles check */
new-modal:has(> label:active) > input { /* UA toggles on mouseup; :active is a hint */ }

/* Pure CSS open/close using :has() relationship */
new-modal:has(> input:checked) > dialog {
  /* show as modal */
  transition-behavior: allow-discrete;
  opacity: 1;
  transform: scale(1);
}
new-modal > dialog {
  border: none;
  border-radius: .75rem;
  padding: 1.25rem;
  max-inline-size: 42rem;

  /* start hidden */
  opacity: 0;
  transform: scale(.97);

  /* animated open/close */
  transition:
    opacity 200ms,
    transform 200ms;

  @starting-style {
    opacity: 0;
    transform: scale(.97);
  }
}

/* Backdrop styling */
new-modal > dialog::backdrop {
  background: color-mix(in oklab, Canvas 70%, black 45%);
  transition: background 200ms;
  @starting-style { background: color-mix(in oklab, Canvas 90%, black 0%); }
}

/* Declarative “modal” effect without JS */
new-modal:has(> input:checked) > dialog::backdrop { /* becomes visible with dialog */
}

/* Keyboard-first UX: make trigger clearly focusable */
new-modal > label {
  inline-size: fit-content;
  padding: .5rem 1rem;
  border: 1px solid currentColor;
  border-radius: .5rem;
}

/* Close: pressing the form button dismisses the dialog semantically */
new-modal > dialog form > button {
  inline-size: auto;
}

/* Optional: open dialog when checkbox is checked (no script) */
@supports selector(dialog:open) {
  new-modal > dialog[open] { display: grid; }
}

/* Auto-focus the first control when opened (HTML handles it) */
new-modal > dialog form > button { }

/* Accessibility niceties */
new-modal > dialog:modal { /* pseudo-class for true modal state if UA supports it */ }
</style>

How it works (quick):

  • State: a hidden checkbox is the source of truth; CSS :has() ties input:checked to the dialog’s open styles.
  • Modal feel: dialog::backdrop gives page‑blocking overlay; :modal (where supported) provides “true” modality semantics.
  • Animation: @starting-style + transition-behavior: allow-discrete; enable smooth open/close without JS hacks.
  • Close: the <form method="dialog"> + <button> dismisses the dialog naturally.
  • Swap option: prefer anchors? Replace the checkbox with an a href="#my-modal" target + :target gating, keeping the same CSS transitions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment