Open demo

CSS code

HTML
<button type="button" class="sh-trigger" aria-haspopup="dialog" aria-controls="sh-board">
  <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
  Board actions
</button>

<dialog class="sh" id="sh-board" aria-label="Board actions">
  <form class="sh-in" method="dialog">
    <span class="sh-handle" aria-hidden="true"></span>
    <p class="sh-title" id="sh-board-group">Board</p>
    <ul aria-labelledby="sh-board-group">
      <li><button value="rename"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4l10-10-4-4L4 16z"/></svg>Rename board</button></li>
      <li><button value="duplicate"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5h10"/></svg>Duplicate</button></li>
      <li><button value="export"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 11l5 5 5-5M5 20h14"/></svg>Export as CSV</button></li>
      <li class="sh-sep"><button value="delete" class="sh-bad"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14M9 7V5h6v2M7 7l1 13h8l1-13"/></svg>Delete board</button></li>
    </ul>
    <button value="cancel" class="sh-cancel">Cancel</button>
  </form>
</dialog>
CSS
/* Bottom sheet of actions built on a modal <dialog>: showModal() makes the page behind inert, keeps focus inside,
   and Esc closes it. Each action is a button in a <form method="dialog">, so choosing one (or Cancel) closes the
   sheet and leaves its value in dialog.returnValue. The sheet slides up with @starting-style. */
.sh-trigger{display:inline-flex;align-items:center;gap:9px;padding:13px 24px;border:0;border-radius:12px;background:#101828;color:#fff;
  font:inherit;font-size:.9rem;font-weight:700;cursor:pointer}
.sh-trigger:hover{background:#344054}
.sh-trigger svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.6}
.sh-trigger:focus-visible{outline:2px solid #4f46e5;outline-offset:3px}
.sh{--sh-ease:cubic-bezier(.32,.72,0,1);--sh-ink:#101828;--sh-danger:#dc2626;--sh-focus:#4f46e5;
  position:fixed;inset:auto 0 0 0;width:100%;max-width:none;max-height:calc(100dvh - 40px);margin:0;padding:0;border:0;
  border-radius:20px 20px 0 0;background:#fff;color:var(--sh-ink);text-align:left;box-shadow:0 -12px 40px rgba(16,24,40,.2);
  transform:translateY(101%);transition:transform .34s var(--sh-ease),overlay .34s allow-discrete,display .34s allow-discrete}
.sh[open]{transform:none}
@starting-style{.sh[open]{transform:translateY(101%)}}
.sh::backdrop{background:rgba(16,24,40,.42);opacity:0;transition:opacity .3s,overlay .3s allow-discrete,display .3s allow-discrete}
.sh[open]::backdrop{opacity:1}
@starting-style{.sh[open]::backdrop{opacity:0}}
.sh-in{padding:10px 14px 16px}
.sh-handle{display:block;width:38px;height:4px;margin:6px auto 14px;border-radius:99px;background:#d5dae4}
.sh-title{margin:0;padding:0 12px 9px;font-size:.74rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#667085}
.sh ul{margin:0;padding:0;list-style:none}
.sh-sep{margin-top:7px;padding-top:7px;border-top:1px solid #eef0f5}
.sh li button{display:flex;align-items:center;gap:13px;width:100%;padding:14px 12px;border:0;border-radius:12px;background:none;
  color:var(--sh-ink);font:inherit;font-size:.95rem;font-weight:600;text-align:left;cursor:pointer}
.sh li button:hover{background:#f4f5f9}
.sh li svg{width:18px;height:18px;stroke:#667085;fill:none;stroke-width:2}
.sh li .sh-bad{color:var(--sh-danger)}
.sh li .sh-bad svg{stroke:var(--sh-danger)}
.sh-cancel{display:block;width:100%;margin-top:9px;padding:14px;border:0;border-radius:12px;background:#f2f4f7;color:#344054;
  font:inherit;font-size:.95rem;font-weight:700;cursor:pointer}
.sh-cancel:hover{background:#e9ecf2}
.sh button:focus-visible{outline:2px solid var(--sh-focus);outline-offset:-2px}
@media (prefers-reduced-motion:reduce){.sh,.sh::backdrop{transition:none}}
JavaScript
// Open the sheet as a modal dialog; a click on the dimmed backdrop (the <dialog> element itself, outside the form)
// closes it, and focus goes back to the button that opened it. Read dialog.returnValue in the "close" handler
// to act on the chosen action.
document.querySelectorAll('[aria-haspopup="dialog"][aria-controls]').forEach(btn => {
  const sheet = document.getElementById(btn.getAttribute('aria-controls'));
  btn.addEventListener('click', () => { sheet.returnValue = ''; sheet.showModal(); });
  sheet.addEventListener('click', e => { if (e.target === sheet) sheet.close('cancel'); });
  sheet.addEventListener('close', () => btn.focus());
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS, Bootstrap 5.3.8 JS bundle

HTML
<button type="button" class="btn sh-trigger" data-bs-toggle="offcanvas" data-bs-target="#sh-board" aria-controls="sh-board" aria-haspopup="dialog">
  <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
  Board actions
</button>

<div class="offcanvas offcanvas-bottom sh" tabindex="-1" id="sh-board" aria-label="Board actions">
  <div class="offcanvas-body">
    <span class="sh-handle" aria-hidden="true"></span>
    <p class="sh-title" id="sh-board-group">Board</p>
    <ul class="list-group" aria-labelledby="sh-board-group">
      <li class="list-group-item"><button type="button" class="list-group-item list-group-item-action" data-bs-dismiss="offcanvas" value="rename"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4l10-10-4-4L4 16z"/></svg>Rename board</button></li>
      <li class="list-group-item"><button type="button" class="list-group-item list-group-item-action" data-bs-dismiss="offcanvas" value="duplicate"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5h10"/></svg>Duplicate</button></li>
      <li class="list-group-item"><button type="button" class="list-group-item list-group-item-action" data-bs-dismiss="offcanvas" value="export"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 11l5 5 5-5M5 20h14"/></svg>Export as CSV</button></li>
      <li class="list-group-item sh-sep"><button type="button" class="list-group-item list-group-item-action sh-bad" data-bs-dismiss="offcanvas" value="delete"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14M9 7V5h6v2M7 7l1 13h8l1-13"/></svg>Delete board</button></li>
    </ul>
    <button type="button" class="btn w-100 sh-cancel" data-bs-dismiss="offcanvas">Cancel</button>
  </div>
</div>
CSS
/* Bootstrap .offcanvas-bottom as an action sheet. The Offcanvas plugin dims the page, traps focus, closes on Esc,
   the backdrop or any button with data-bs-dismiss, and returns focus to the trigger. Actions are a .list-group of buttons. */
.sh-trigger{--bs-btn-padding-x:24px;--bs-btn-padding-y:13px;--bs-btn-font-size:.9rem;--bs-btn-font-weight:700;--bs-btn-border-radius:12px;--bs-btn-border-width:0;
  --bs-btn-color:#fff;--bs-btn-bg:#101828;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#344054;--bs-btn-active-color:#fff;--bs-btn-active-bg:#344054;
  display:inline-flex;align-items:center;gap:9px;line-height:normal}
.sh-trigger svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.6}
.sh-trigger:focus-visible{outline:2px solid #4f46e5;outline-offset:3px;box-shadow:none}
.sh.offcanvas{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;--bs-offcanvas-color:#101828;
  --bs-offcanvas-transition:transform .34s cubic-bezier(.32,.72,0,1);max-height:calc(100dvh - 40px);
  border-radius:20px 20px 0 0;box-shadow:0 -12px 40px rgba(16,24,40,.2);text-align:left}
.sh .offcanvas-body{padding:10px 14px 16px}
.offcanvas-backdrop{background-color:#101828}
.offcanvas-backdrop.show{opacity:.42}
.sh-handle{display:block;width:38px;height:4px;margin:6px auto 14px;border-radius:99px;background:#d5dae4}
.sh-title{margin:0;padding:0 12px 9px;font-size:.74rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#667085}
.sh .list-group{--bs-list-group-border-width:0;--bs-list-group-item-padding-x:12px;--bs-list-group-item-padding-y:14px;--bs-list-group-color:#101828;
  --bs-list-group-action-color:#101828;--bs-list-group-action-hover-color:#101828;--bs-list-group-action-hover-bg:#f4f5f9;
  --bs-list-group-action-active-color:#101828;--bs-list-group-action-active-bg:#eef0f5;gap:0}
.sh .list-group-item{display:flex;align-items:center;gap:13px;border-radius:12px;font-size:.95rem;font-weight:600;line-height:normal}
.sh .list-group-item svg{width:18px;height:18px;stroke:#667085;fill:none;stroke-width:2}
.sh .list-group-item.sh-bad{color:#dc2626}
.sh .list-group-item.sh-bad svg{stroke:#dc2626}
.sh .list-group>li{padding:0;border-radius:0}
.sh .list-group>li.sh-sep{margin-top:7px;padding-top:7px;border-top:1px solid #eef0f5}
.sh-cancel{--bs-btn-padding-y:14px;--bs-btn-font-size:.95rem;--bs-btn-font-weight:700;--bs-btn-border-radius:12px;--bs-btn-border-width:0;
  --bs-btn-color:#344054;--bs-btn-bg:#f2f4f7;--bs-btn-hover-color:#344054;--bs-btn-hover-bg:#e9ecf2;--bs-btn-active-color:#344054;--bs-btn-active-bg:#e9ecf2;
  margin-top:9px;line-height:normal}
.sh button:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px;box-shadow:none}
@media (prefers-reduced-motion:reduce){.sh.offcanvas{transition:none}}

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<button type="button" class="inline-flex cursor-pointer items-center gap-[9px] rounded-xl bg-[#101828] px-6 py-[13px] text-[.9rem] font-bold text-white hover:bg-[#344054] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#4f46e5]" aria-haspopup="dialog" aria-controls="sh-board">
  <svg class="size-4 fill-none stroke-current stroke-[2.6]" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
  Board actions
</button>

<dialog class="text-left fixed inset-x-0 top-auto bottom-0 m-0 max-h-[calc(100dvh-40px)] w-full max-w-none translate-y-[101%] rounded-t-[20px] bg-white p-0 text-[#101828] shadow-[0_-12px_40px_rgba(16,24,40,.2)] transition-[translate,overlay,display] transition-discrete duration-[340ms] ease-[cubic-bezier(.32,.72,0,1)] open:translate-y-0 starting:open:translate-y-[101%] backdrop:bg-[#101828]/42 backdrop:opacity-0 backdrop:transition-[opacity,overlay,display] backdrop:transition-discrete backdrop:duration-300 open:backdrop:opacity-100 starting:open:backdrop:opacity-0 motion-reduce:transition-none motion-reduce:backdrop:transition-none" id="sh-board" aria-label="Board actions">
  <form class="px-3.5 pt-2.5 pb-4" method="dialog">
    <span class="mx-auto mt-1.5 mb-3.5 block h-1 w-[38px] rounded-full bg-[#d5dae4]" aria-hidden="true"></span>
    <p class="px-3 pb-[9px] text-[.74rem] font-bold tracking-[.08em] text-[#667085] uppercase" id="sh-board-group">Board</p>
    <ul aria-labelledby="sh-board-group">
      <li><button class="flex w-full cursor-pointer items-center gap-[13px] rounded-xl px-3 py-3.5 text-left text-[.95rem] font-semibold hover:bg-[#f4f5f9] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5]" value="rename"><svg class="size-[18px] fill-none stroke-[#667085] stroke-2" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4l10-10-4-4L4 16z"/></svg>Rename board</button></li>
      <li><button class="flex w-full cursor-pointer items-center gap-[13px] rounded-xl px-3 py-3.5 text-left text-[.95rem] font-semibold hover:bg-[#f4f5f9] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5]" value="duplicate"><svg class="size-[18px] fill-none stroke-[#667085] stroke-2" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5h10"/></svg>Duplicate</button></li>
      <li><button class="flex w-full cursor-pointer items-center gap-[13px] rounded-xl px-3 py-3.5 text-left text-[.95rem] font-semibold hover:bg-[#f4f5f9] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5]" value="export"><svg class="size-[18px] fill-none stroke-[#667085] stroke-2" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 11l5 5 5-5M5 20h14"/></svg>Export as CSV</button></li>
      <li class="mt-[7px] border-t border-[#eef0f5] pt-[7px]"><button value="delete" class="flex w-full cursor-pointer items-center gap-[13px] rounded-xl px-3 py-3.5 text-left text-[.95rem] font-semibold hover:bg-[#f4f5f9] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] text-[#dc2626]"><svg class="size-[18px] fill-none stroke-[#dc2626] stroke-2" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14M9 7V5h6v2M7 7l1 13h8l1-13"/></svg>Delete board</button></li>
    </ul>
    <button value="cancel" class="mt-[9px] block w-full cursor-pointer rounded-xl bg-[#f2f4f7] p-3.5 text-[.95rem] font-bold text-[#344054] hover:bg-[#e9ecf2] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5]">Cancel</button>
  </form>
</dialog>
JavaScript
// Open the sheet as a modal dialog; a click on the dimmed backdrop (the <dialog> element itself, outside the form)
// closes it, and focus goes back to the button that opened it. Read dialog.returnValue in the "close" handler
// to act on the chosen action.
document.querySelectorAll('[aria-haspopup="dialog"][aria-controls]').forEach(btn => {
  const sheet = document.getElementById(btn.getAttribute('aria-controls'));
  btn.addEventListener('click', () => { sheet.returnValue = ''; sheet.showModal(); });
  sheet.addEventListener('click', e => { if (e.target === sheet) sheet.close('cancel'); });
  sheet.addEventListener('close', () => btn.focus());
});

About this set

A mobile-style action sheet that slides up from the bottom of the screen over a dimmed page. It opens from a dark Board actions button and holds a grab handle, a small Board heading, three actions with icons (Rename board, Duplicate, Export as CSV), a destructive Delete board in red below a divider, and a full-width Cancel button. Use it for per-item actions on phones and in compact web apps, where a dropdown would be too small to tap comfortably. The sheet is a native dialog opened with showModal(), so the browser makes the rest of the page inert, keeps focus inside, closes it on Esc and draws the backdrop. The actions are buttons in a form with method=”dialog”: choosing one closes the sheet and leaves its value in dialog.returnValue for your code to act on, with no click handler per item. A few lines of script open it, close it on a backdrop click and put focus back on the trigger. The slide and fade use @starting-style, so no timers are needed. The Bootstrap version uses .offcanvas-bottom with a .list-group of buttons.

What’s included

  • Native modal dialog: inert page, focus kept inside, Esc to close
  • Actions in a form method="dialog" that report dialog.returnValue
  • Slide-up and backdrop fade with @starting-style
  • Backdrop click and Cancel close the sheet and refocus the trigger
  • Bootstrap version uses .offcanvas-bottom and .list-group

Accessibility

The sheet is a modal dialog labelled Board actions, and its trigger declares aria-haspopup="dialog". The page behind is inert while it is open, Esc closes it, and focus returns to the trigger. Every action is a real button with visible text and an indigo focus outline, and the delete action says Delete as well as being red. Motion is removed under prefers-reduced-motion.

Customise it

Change --sh-ink, --sh-danger and --sh-focus on .sh, the 20px corner radius, and the backdrop colour on .sh::backdrop. In Bootstrap the sizes and colours are the --bs-offcanvas-* and --bs-list-group-* variables plus the .offcanvas-backdrop rules; in Tailwind edit backdrop:bg-[#101828]/42 and text-[#dc2626].