/* Bottom and side sheets on the native <dialog> element. showModal() puts the sheet in the top layer,
keeps focus inside it, closes it on Esc and makes the page behind inert. The UA makes a modal dialog
position:fixed with inset:0, so the margins below are what pin each sheet to an edge. */
.sh-btn{--sh-accent:#6d28d9;display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:42px;padding:9px 16px;border:1px solid #cbd5e1;border-radius:10px;
background:#fff;color:#0f172a;font-family:inherit;font-size:.9rem;font-weight:600;line-height:1.2;text-decoration:none;cursor:pointer;transition:background-color .15s,border-color .15s,box-shadow .15s}
.sh-btn:hover{background:#f5f3ff;border-color:#a78bfa}
.sh-btn:focus-visible{outline:2px solid var(--sh-accent);outline-offset:2px}
.sh-btn.is-primary{background:var(--sh-accent);border-color:var(--sh-accent);color:#fff;box-shadow:0 6px 16px -8px rgba(109,40,217,.7)}
.sh-btn.is-primary:hover{background:#5b21b6;border-color:#5b21b6}
.sh-btn svg{width:18px;height:18px;flex:none}
.sh{--sh-accent:#6d28d9;--sh-ink:#0f172a;--sh-muted:#475569;--sh-line:#ebe7f5;--sh-tint:#f5f3ff;--sh-tint-2:#ede9fe;--sh-field:#8e89a8;--sh-radius:24px;
max-width:none;max-height:none;padding:0;border:0;background:#fff;color:var(--sh-ink);font-size:.95rem;line-height:1.55;overflow:hidden;overscroll-behavior:contain;
box-shadow:0 0 0 1px rgba(30,27,75,.06),0 -16px 48px -16px rgba(30,27,75,.4)}
.sh[open]{display:flex;flex-direction:column}
.sh::backdrop{background:rgba(24,20,56,.5);backdrop-filter:blur(2px)}
.sh[open]::backdrop{animation:sh-fade .3s ease-out}
@keyframes sh-up{from{transform:translateY(100%)}}
@keyframes sh-left{from{transform:translateX(100%)}}
@keyframes sh-pop{from{opacity:0;transform:translateY(10px) scale(.97)}}
@keyframes sh-fade{from{opacity:0}}
html:has(.sh:modal){overflow:hidden} /* stop the page scrolling behind an open sheet */
/* Bottom sheet: pinned to the bottom edge at every width, full width on phones */
.sh-bottom{width:min(560px,100%);max-height:calc(100dvh - 24px);margin:auto auto 0;border-radius:var(--sh-radius) var(--sh-radius) 0 0}
.sh-bottom[open]{animation:sh-up .34s cubic-bezier(.22,1,.36,1)}
/* Side sheet: full height on the right edge */
.sh-side{width:min(400px,100%);height:100%;margin:0 0 0 auto;border-radius:20px 0 0 20px;box-shadow:0 0 0 1px rgba(30,27,75,.06),-16px 0 48px -16px rgba(30,27,75,.4)}
.sh-side[open]{animation:sh-left .34s cubic-bezier(.22,1,.36,1)}
/* Adaptive: a bottom sheet on phones, a centred dialog from 640px */
.sh-adapt{width:100%;max-height:calc(100dvh - 24px);margin:auto auto 0;border-radius:var(--sh-radius) var(--sh-radius) 0 0}
.sh-adapt[open]{animation:sh-up .34s cubic-bezier(.22,1,.36,1)}
.sh-form{display:flex;flex-direction:column;flex:1;min-height:0;margin:0}
.sh-handle{flex:none;display:block;width:40px;height:5px;margin:10px auto 0;border-radius:999px;background:#d4cfe6}
.sh-head{flex:none;display:flex;align-items:center;gap:14px;padding:14px 24px 16px}
.sh-titles{flex:1;min-width:0}
.sh-title{margin:0;font-size:1.1rem;font-weight:700;letter-spacing:-.01em;line-height:1.35;overflow-wrap:anywhere}
.sh-sub{margin:2px 0 0;color:var(--sh-muted);font-size:.85rem}
.sh-icon{flex:none;display:grid;place-items:center;width:44px;height:44px;border-radius:12px;background:linear-gradient(135deg,var(--sh-tint),var(--sh-tint-2));color:var(--sh-accent)}
.sh-icon svg{width:22px;height:22px}
.sh-x{flex:none;display:grid;place-items:center;width:36px;height:36px;margin:-4px -8px 0 0;padding:0;border:0;border-radius:10px;background:transparent;color:var(--sh-muted);cursor:pointer;
transition:background-color .15s,color .15s}
.sh-x:hover{background:var(--sh-tint);color:var(--sh-ink)}
.sh-x:focus-visible{outline:2px solid var(--sh-accent);outline-offset:1px}
.sh-x svg{width:20px;height:20px}
.sh-body{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain}
.sh-body:focus-visible{outline:2px solid var(--sh-accent);outline-offset:-4px}
.sh-foot{flex:none;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:10px;padding:16px 24px;border-top:1px solid var(--sh-line);background:#fff}
/* 1. Action sheet: share targets, an action list and a Cancel button */
.sh-targets{list-style:none;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px;margin:0;padding:4px 14px 14px}
.sh-target{display:flex;flex-direction:column;align-items:center;gap:8px;width:100%;padding:8px 2px;border:0;border-radius:14px;background:none;color:var(--sh-ink);
font:inherit;font-size:.8rem;font-weight:600;line-height:1.25;cursor:pointer}
.sh-target span{display:grid;place-items:center;width:54px;height:54px;border-radius:50%;background:var(--sh-tint);color:var(--sh-accent);transition:background-color .15s}
.sh-target svg{width:24px;height:24px}
.sh-target:hover span{background:var(--sh-tint-2)}
.sh-target:focus-visible{outline:2px solid var(--sh-accent);outline-offset:-2px}
.sh-actions{list-style:none;margin:0;padding:8px 12px;border-top:1px solid var(--sh-line)}
.sh-action{display:flex;align-items:center;gap:14px;width:100%;min-height:48px;padding:10px 12px;border:0;border-radius:12px;background:none;color:var(--sh-ink);
font:inherit;font-weight:500;text-align:left;cursor:pointer;transition:background-color .15s}
.sh-action svg{width:20px;height:20px;flex:none;color:var(--sh-muted)}
.sh-action:hover{background:var(--sh-tint)}
.sh-action:focus-visible{outline:2px solid var(--sh-accent);outline-offset:-2px}
.sh-action.is-danger,.sh-action.is-danger svg{color:#b91c1c}
.sh-action.is-danger:hover{background:#fef2f2}
.sh-bottom .sh-foot{padding:12px 16px calc(16px + env(safe-area-inset-bottom))}
.sh-bottom .sh-foot .sh-btn{width:100%;min-height:48px}
/* 2. Side sheet: filters */
.sh-side .sh-head{align-items:flex-start;padding:20px 24px 16px;border-bottom:1px solid var(--sh-line)}
.sh-side .sh-body{padding:0 24px}
.sh-group{margin:0;padding:18px 0;border:0;border-bottom:1px solid var(--sh-line);min-width:0}
.sh-group:last-child{border-bottom:0}
.sh-legend{float:left;width:100%;margin:0 0 10px;padding:0;color:var(--sh-ink);font-size:.75rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
.sh-legend+*{clear:left}
.sh-check{display:flex;align-items:center;gap:12px;padding:7px 0;cursor:pointer}
.sh-check input{flex:none;width:18px;height:18px;margin:0;accent-color:var(--sh-accent);cursor:pointer}
.sh-check input:focus-visible{outline:2px solid var(--sh-accent);outline-offset:2px}
.sh-count{margin-left:auto;min-width:34px;padding:1px 8px;border-radius:999px;background:var(--sh-tint);color:#5b21b6;font-size:.75rem;font-weight:600;text-align:center}
.sh-chips{display:flex;flex-wrap:wrap;gap:8px}
.sh-chip input{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;opacity:0}
.sh-chip span{display:grid;place-items:center;min-width:54px;height:40px;padding:0 14px;border:1px solid var(--sh-field);border-radius:999px;background:#fff;color:var(--sh-ink);
font-size:.875rem;font-weight:600;cursor:pointer;transition:background-color .15s,border-color .15s,color .15s}
.sh-chip span:hover{background:var(--sh-tint)}
.sh-chip input:checked+span{border-color:var(--sh-accent);background:var(--sh-accent);color:#fff}
.sh-chip input:focus-visible+span{outline:2px solid var(--sh-accent);outline-offset:2px}
.sh-range{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.sh-field label{display:block;margin-bottom:6px;color:var(--sh-muted);font-size:.8rem;font-weight:600}
.sh-input{display:block;width:100%;height:42px;margin:0;padding:8px 12px;border:1px solid var(--sh-field);border-radius:10px;background:#fff;color:var(--sh-ink);font:inherit;font-size:.9rem;
transition:border-color .15s,box-shadow .15s}
.sh-input:focus{outline:none;border-color:var(--sh-accent);box-shadow:0 0 0 3px #ddd6fe}
.sh-row{display:flex;align-items:center;gap:16px}
.sh-row b{display:block;font-weight:600}
.sh-row small{display:block;color:var(--sh-muted);font-size:.8rem}
.sh-switch{flex:none;appearance:none;width:44px;height:26px;margin:0 0 0 auto;border:0;border-radius:999px;cursor:pointer;
background:var(--sh-field) radial-gradient(circle,#fff 0 9px,transparent 9.5px) 2px 50%/22px 22px no-repeat;transition:background-color .2s,background-position .2s}
.sh-switch:checked{background-color:var(--sh-accent);background-position:calc(100% - 2px) 50%}
.sh-switch:focus-visible{outline:2px solid var(--sh-accent);outline-offset:2px}
.sh-side .sh-foot{flex-wrap:nowrap;padding:16px 24px calc(16px + env(safe-area-inset-bottom));box-shadow:0 -8px 20px -14px rgba(30,27,75,.35)}
.sh-side .sh-foot .sh-btn{flex:none;white-space:nowrap}
.sh-side .sh-foot .sh-btn.is-primary{flex:1 1 auto}
/* 3. Adaptive address form */
.sh-fields{display:grid;grid-template-columns:1fr 1fr;gap:14px 12px;padding:4px 24px 22px}
.sh-fields .is-wide{grid-column:1/-1}
.sh-select{position:relative}
.sh-select select{appearance:none;padding-right:40px;cursor:pointer}
.sh-select svg{position:absolute;right:12px;top:50%;width:18px;height:18px;margin-top:-9px;color:var(--sh-muted);pointer-events:none}
.sh-adapt .sh-foot{padding-bottom:calc(16px + env(safe-area-inset-bottom))}
@media (min-width:640px){
.sh-adapt{width:min(480px,calc(100% - 32px));max-height:calc(100dvh - 56px);margin:auto;border-radius:16px;box-shadow:0 0 0 1px rgba(30,27,75,.06),0 28px 64px -18px rgba(30,27,75,.45)}
.sh-adapt[open]{animation:sh-pop .22s cubic-bezier(.2,.8,.2,1)}
.sh-adapt .sh-handle{display:none}
.sh-adapt .sh-head{padding-top:22px}
.sh-adapt .sh-foot{padding-bottom:16px;background:#faf9fe}
}
@media (max-width:600px){
.sh-head,.sh-side .sh-head{padding-left:18px;padding-right:18px}
.sh-side .sh-body{padding:0 18px}
.sh-fields{padding-left:18px;padding-right:18px}
.sh-foot,.sh-side .sh-foot{padding-left:18px;padding-right:18px}
.sh-foot .sh-btn{flex:1 1 auto}
.sh-targets{padding-left:8px;padding-right:8px}
}
@media (max-width:440px){.sh-side{border-radius:0}}
@media (prefers-reduced-motion:reduce){
.sh[open],.sh[open]::backdrop{animation:none}
.sh-btn,.sh-x,.sh-target span,.sh-action,.sh-chip span,.sh-input,.sh-switch{transition:none}
}
JavaScript
// Open a sheet from any button with data-open="dialog-id". showModal() traps focus, closes on Esc
// and makes the page behind inert. Buttons inside <form method="dialog"> close it with no script.
document.querySelectorAll('[data-open]').forEach(function (btn) {
var dialog = document.getElementById(btn.getAttribute('data-open'));
if (!dialog) return;
btn.addEventListener('click', function () {
dialog.showModal();
dialog.addEventListener('close', function () { btn.focus(); }, { once: true }); // focus back to the trigger
});
// A click on the backdrop lands on the <dialog> itself (its form fills the box), so close it
dialog.addEventListener('click', function (e) { if (e.target === dialog) dialog.close(); });
});
// "Clear all" in the filters sheet: untick every box and empty the price fields; the sheet stays open
document.querySelectorAll('[data-clear]').forEach(function (btn) {
btn.addEventListener('click', function () {
btn.form.querySelectorAll('input').forEach(function (el) {
if (el.type === 'checkbox') el.checked = false; else el.value = '';
});
});
});
// "Clear all" in the filters sheet: untick every box and empty the price fields; the sheet stays open
document.querySelectorAll('[data-clear]').forEach(function (btn) {
btn.addEventListener('click', function () {
btn.closest('.modal-content').querySelectorAll('input').forEach(function (el) {
if (el.type === 'checkbox') el.checked = false; else el.value = '';
});
});
});
/* Slide-in keyframes for the sheets and a fade for the backdrop (used through motion-safe:open:animate-[…]) */
@keyframes sh-up { from { transform: translateY(100%); } }
@keyframes sh-left { from { transform: translateX(100%); } }
@keyframes sh-pop { from { opacity: 0; transform: translateY(10px) scale(.97); } }
@keyframes sh-fade { from { opacity: 0; } }
/* Stop the page scrolling behind an open sheet */
html:has(dialog:modal) { overflow: hidden; }
JavaScript
// Open a sheet from any button with data-open="dialog-id". showModal() traps focus, closes on Esc
// and makes the page behind inert. Buttons inside <form method="dialog"> close it with no script.
document.querySelectorAll('[data-open]').forEach(function (btn) {
var dialog = document.getElementById(btn.getAttribute('data-open'));
if (!dialog) return;
btn.addEventListener('click', function () {
dialog.showModal();
dialog.addEventListener('close', function () { btn.focus(); }, { once: true }); // focus back to the trigger
});
// A click on the backdrop lands on the <dialog> itself (its form fills the box), so close it
dialog.addEventListener('click', function (e) { if (e.target === dialog) dialog.close(); });
});
// "Clear all" in the filters sheet: untick every box and empty the price fields; the sheet stays open
document.querySelectorAll('[data-clear]').forEach(function (btn) {
btn.addEventListener('click', function () {
btn.form.querySelectorAll('input').forEach(function (el) {
if (el.type === 'checkbox') el.checked = false; else el.value = '';
});
});
});
About this set
Three modal sheets that slide in from the edge of the screen. The bottom sheet is an action sheet for a file: a grab handle, the file name and size, four round share targets (Copy link, Email, Messages, Nearby), a list of actions ending in a red Delete file, and a full-width Cancel. It stays on the bottom edge at every width, full width on phones and 560px wide on desktop. The side sheet is a filter panel for a shop: full height on the right, with category checkboxes and counts, size chips, a price range, an in-stock switch and a footer that stays visible while the options scroll. The third is a delivery address form that is a bottom sheet on phones and a centred dialog from 640px. Use a bottom sheet for short lists of actions, a side sheet for filters or details the user checks against the page, and the adaptive form when one dialog has to suit thumbs and mice. The plain and Tailwind versions use the native dialog element opened with showModal(), placed on an edge with margin overrides; the Bootstrap version keeps the Modal plugin and swaps its fade for a slide. Focus stays inside, Esc and the backdrop close them, and the page behind does not scroll.
What’s included
Bottom, right-side and adaptive sheets built on a modal dialog
Slide-in from the edge, turned off under reduced motion
Safe-area padding under the last button for phones with a home indicator
Scrolling filter body with a footer that stays visible
Bootstrap version uses the Modal plugin, re-anchored with a few custom classes
Accessibility
Each sheet is a modal dialog labelled by its visible heading; the grab handle is decorative and hidden from assistive technology. Focus stays inside while a sheet is open, Esc closes it and focus returns to the button that opened it. Every button, chip, checkbox, switch and field has a visible focus outline and a text label, and the scrolling filter list is a labelled region that can be focused and scrolled from the keyboard. All slide and fade animations stop when the user prefers reduced motion.
Customise it
In the plain version change --sh-accent, --sh-tint and --sh-field on .sh, and the widths in .sh-bottom (560px) and .sh-side (400px). In Bootstrap set --bs-modal-width on .sh-bottom and .sh-adapt and the --bs-btn-* variables on .sh-btn. In Tailwind swap the #6d28d9 values and the w-[min(560px,100%)] and w-[min(400px,100%)] classes.