On this page
Composed examples
These are mixed examples of composed elements, not one widget.
Different kinds of recipes share this page for now. Each one stacks
markup and CSS you already have: popovers, offcanvas, toasts, tabs,
progress, buttons, and form checks. No extra
pe-*
element, and no script that measures or repositions layout. Checked
counts use the
<output>
contract.
| What runs it | JavaScript | |
|---|---|---|
| Bootstrap 5.3 | Not one plugin. These are recipes on Dropdown, Offcanvas, Toast, Tabs, Progress, Buttons, and Output. | Varies by recipe |
| This repo | Popover, dialog, details, progress, form checks, search, and btn-check. Optional pe-toast and pe-tabs as on those pages. | 0 KB. Optional pe-toast, pe-tabs, and src/behaviors/count.js for output[data-controls]. |
| Avoid |
|
|---|
animation-timeline: scroll() (not in Firefox). The
progress ring reads attr() into a custom property where
that works; examples also set --bs-progress-value.
Without those features the controls still work; the extra motion
does not. The interest preview needs interestfor and
popover="hint"; aria-description stays.
Context menu
Right-click is the context menu. The
contextmenu event also fires from the Menu key or
Shift+F10, so that path needs no extra script. Left-click
Actions is a command menu on a button — useful as a
visible control, not a second way to click the same pixel. Both can
share one popover="auto"; the invoker is the anchor.
Items are buttons, not role="menu". There is no CSS
path from the last pointer coordinates, so a right-click still
tethers to the clicked element rather than the pixel.
oncontextmenu calls
showPopover({ source: event.target }) after
preventDefault().
On a host
Right-click the card (or the paragraph). Left-click Actions to open the same menu from that button.
Right-click this card. Actions is a separate left-click control.
<div
class="card context-host"
oncontextmenu="event.preventDefault(); this.querySelector('[popover]').showPopover({source: event.target})"
>
<div class="card-body">
<p class="mb-2">Right-click this card. Actions is a separate left-click control.</p>
<button type="button" class="btn btn-sm btn-secondary" command="toggle-popover" commandfor="composed-context-host">
Actions
</button>
</div>
<ul class="dropdown-menu dropdown-menu-context" popover="auto" id="composed-context-host">
<li><button type="button" class="dropdown-item">Edit</button></li>
<li><button type="button" class="dropdown-item">Duplicate</button></li>
<li><hr class="dropdown-divider"></li>
<li><button type="button" class="dropdown-item text-danger">Delete</button></li>
</ul>
</div> From a button
Left-click only. The menu is a command menu, not a context menu.
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" command="toggle-popover" commandfor="composed-context-btn">
Actions
</button>
<ul class="dropdown-menu dropdown-menu-context" popover="auto" id="composed-context-btn">
<li><button type="button" class="dropdown-item">Edit</button></li>
<li><button type="button" class="dropdown-item">Duplicate</button></li>
<li><hr class="dropdown-divider"></li>
<li><button type="button" class="dropdown-item text-danger">Delete</button></li>
</ul>
</div> Drawer with drill-down
A dialog.offcanvas opened with command.
Nested details in .nav-tree are the
levels. The tree is not tied to the drawer: the same class works
on the page. This is not a JavaScript scroll-snap drawer.
Offcanvas tree
Open the drawer, then expand a branch. Summaries are the controls.
<button type="button" class="btn btn-primary" command="show-modal" commandfor="composed-drawer">
Open drawer
</button>
<dialog class="offcanvas offcanvas-start" id="composed-drawer" closedby="any" aria-labelledby="composed-drawer-label">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="composed-drawer-label">Catalog</h5>
<button type="button" class="btn-close" command="close" commandfor="composed-drawer" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<nav class="nav-tree" aria-label="Catalog">
<details open>
<summary>Products</summary>
<details open>
<summary>Shoes</summary>
<a href="#">Runners</a>
<a href="#">Boots</a>
</details>
<details>
<summary>Jackets</summary>
<a href="#">Rain</a>
<a href="#">Fleece</a>
</details>
</details>
<details open>
<summary>Account</summary>
<a href="#">Orders</a>
<a href="#">Settings</a>
</details>
</nav>
</div>
</dialog> On the page
Same .nav-tree without a dialog. Links indent under their parent.
<div class="card" style="max-width: 22rem">
<div class="card-body">
<nav class="nav-tree" aria-label="On-page catalog">
<details open>
<summary>Products</summary>
<details open>
<summary>Shoes</summary>
<a href="#">Runners</a>
<a href="#">Boots</a>
</details>
<details>
<summary>Jackets</summary>
<a href="#">Rain</a>
<a href="#">Fleece</a>
</details>
</details>
<details open>
<summary>Account</summary>
<a href="#">Orders</a>
<a href="#">Settings</a>
</details>
</nav>
</div>
</div> Toast countdown
pe-toast already hides after data-delay.
It also sets --pe-toast-delay so a CSS bar can match
that time. Hover and focus pause the timer and the bar; leaving
starts a full delay again.
Autohide bar
Show the toast. The bar empties with the delay. Hover to pause.
<script type="module" src="./node_modules/bootstrap-html/src/behaviors/toast.js"></script>
<button type="button" class="btn btn-primary" command="show-popover" commandfor="composed-toast">
Show toast
</button>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<pe-toast class="toast" id="composed-toast" popover="manual" data-delay="5000" role="status" aria-live="polite" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto">Bootstrap HTML</strong>
<small>just now</small>
<button type="button" class="btn-close" command="hide-popover" commandfor="composed-toast" aria-label="Close"></button>
</div>
<div class="toast-body">Hover the toast to pause the bar. Leaving starts the delay again.</div>
</pe-toast>
</div> Sliding tab underline
Inside pe-tabs, the selected
.nav-link[aria-selected="true"] is an anchor. The
tablist paints one ::after. Ordinary
.nav-tabs page links keep Bootstrap’s static border.
Selected tab
Choose a tab. The underline moves with inset, not position-area.
The underline follows the selected tab.
This is the profile pane.
This is the contact pane.
<script type="module" src="./node_modules/bootstrap-html/src/behaviors/tabs.js"></script>
<pe-tabs>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="composed-home-tab" type="button" role="tab" aria-controls="composed-home-pane" aria-selected="true">Home</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="composed-profile-tab" type="button" role="tab" aria-controls="composed-profile-pane" aria-selected="false" tabindex="-1">Profile</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="composed-contact-tab" type="button" role="tab" aria-controls="composed-contact-pane" aria-selected="false" tabindex="-1">Contact</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane active show" id="composed-home-pane" role="tabpanel" aria-labelledby="composed-home-tab" tabindex="0">
<p class="mt-3 mb-0">The underline follows the selected tab.</p>
</div>
<div class="tab-pane" id="composed-profile-pane" role="tabpanel" aria-labelledby="composed-profile-tab" tabindex="0">
<p class="mt-3 mb-0">This is the profile pane.</p>
</div>
<div class="tab-pane" id="composed-contact-pane" role="tabpanel" aria-labelledby="composed-contact-tab" tabindex="0">
<p class="mt-3 mb-0">This is the contact pane.</p>
</div>
</div>
</pe-tabs> Progress ring
<progress class="progress-ring"> is still a
progress element. A conic gradient is the fill. Set
--bs-progress-value to the same number as
value when max is 100. Do not also add
.progress; that class is the linear bar.
Determinate rings
25, 70, and 100. .text-info recolors the fill. 100 uses the success token.
<div class="d-flex flex-wrap gap-4 align-items-center">
<div class="progress-ring-wrapper">
<progress class="progress-ring" value="25" max="100" style="--bs-progress-value: 25" aria-label="Upload 25 percent">25%</progress>
<span class="progress-ring-content">25%</span>
</div>
<div class="progress-ring-wrapper">
<progress class="progress-ring text-info" value="70" max="100" style="--bs-progress-value: 70" aria-label="Sync 70 percent">70%</progress>
<span class="progress-ring-content">70%</span>
</div>
<div class="progress-ring-wrapper">
<progress class="progress-ring" value="100" max="100" style="--bs-progress-value: 100" aria-label="Complete">100%</progress>
<span class="progress-ring-content">100%</span>
</div>
</div> Scroll progress and shrinking header
Opt-in classes on a nested .scroll-demo scroller, not
the site header. The bar is decorative and
aria-hidden="true". There is no scroll-listener
fallback.
Nested scroller
Scroll inside the panel. The header shortens over the first 8 rem.
Scroll this panel. The header shrinks and the bar tracks how far you have read. These classes stay on a nested scroller so they do not move the docs chrome.
Anchor positioning, popovers, and native widgets on this site are unchanged. The animation is decorative: without scroll-driven animations the header stays tall and the bar is omitted.
Keep going. The range is the first 8 rem of this scroller, then the header stays compact.
Copy below the fold is here so you can finish the shrink and still have distance left. The bar should not hit 100% the moment the header goes compact.
Recipes on this page stay independent. Nested details, tabs, and toasts do not share this timeline. Nothing here measures scroll position.
Prefer a nested scroller like this when you try the pattern in an app. Putting the shrink class on the site header would pin and shrink the docs chrome.
The bar is one sticky strip at the top of the panel. It only mirrors how far you have scrolled. The heading in the header is the visible landmark.
Firefox does not run scroll-driven animations yet. There the header stays tall and the bar is not shown. That is the documented fallback, not a second script.
If you reduce motion, both animations stay off. The panel still scrolls; the header just does not change height.
A little more height so the shrink can finish before you run out of scroll. Keep moving until the bar is most of the way across.
The progress bar is the full scroll of this panel. The header only uses the first 8 rem. After that the compact height holds while you read the rest.
More filler so the last stretch is obvious. The named timeline still drives the bar after the header range has ended.
Weeknotes, invoices, and a long changelog would look like this in a real card: enough copy that you actually scroll, not a one-screen stub.
One more block for the last part of the travel. If the bar is near the end, the header should already be compact and stay there.
Almost there. Scroll a bit further so the thumb reaches the bottom of this panel.
End of the demo column. Scroll back up to restore the tall header and empty the bar.
<div class="scroll-demo">
<div class="scroll-progress" aria-hidden="true"></div>
<header class="header-shrink">Brand</header>
<div class="p-3">
<p>Scroll this panel. The header shrinks and the bar tracks how far you have read. These classes stay on a nested scroller so they do not move the docs chrome.</p>
<p>Anchor positioning, popovers, and native widgets on this site are unchanged. The animation is decorative: without scroll-driven animations the header stays tall and the bar is omitted.</p>
<p>Keep going. The range is the first 8 rem of this scroller, then the header stays compact.</p>
<p>Copy below the fold is here so you can finish the shrink and still have distance left. The bar should not hit 100% the moment the header goes compact.</p>
<p>Recipes on this page stay independent. Nested details, tabs, and toasts do not share this timeline. Nothing here measures scroll position.</p>
<p>Prefer a nested scroller like this when you try the pattern in an app. Putting the shrink class on the site header would pin and shrink the docs chrome.</p>
<p>The bar is one sticky strip at the top of the panel. It only mirrors how far you have scrolled. The heading in the header is the visible landmark.</p>
<p>Firefox does not run scroll-driven animations yet. There the header stays tall and the bar is not shown. That is the documented fallback, not a second script.</p>
<p>If you reduce motion, both animations stay off. The panel still scrolls; the header just does not change height.</p>
<p>A little more height so the shrink can finish before you run out of scroll. Keep moving until the bar is most of the way across.</p>
<p>The progress bar is the full scroll of this panel. The header only uses the first 8 rem. After that the compact height holds while you read the rest.</p>
<p>More filler so the last stretch is obvious. The named timeline still drives the bar after the header range has ended.</p>
<p>Weeknotes, invoices, and a long changelog would look like this in a real card: enough copy that you actually scroll, not a one-screen stub.</p>
<p>One more block for the last part of the travel. If the bar is near the end, the header should already be compact and stay there.</p>
<p>Almost there. Scroll a bit further so the thumb reaches the bottom of this panel.</p>
<p>End of the demo column. Scroll back up to restore the tall header and empty the bar.</p>
</div>
</div> Checked count
The contract lives on
Output:
<output data-controls> and
src/behaviors/count.js. This page only shows recipes
that use it.
Multi-select dropdown
Checkboxes in a popover="auto" menu. They stay there
so a closed popover is not in the tab order. The same
<output data-controls> sits beside the toggle as a
positioned badge. The button name stays “Tags”.
:has(:checked) hides the badge when nothing is selected.
This is not <select multiple>.
Counted checks
Open the menu and toggle tags. The pill tracks how many are checked, including when the menu is closed.
<script type="module" src="./node_modules/bootstrap-html/src/behaviors/count.js"></script>
<div class="dropdown dropdown-multiselect">
<button class="btn btn-primary dropdown-toggle" type="button" command="toggle-popover" commandfor="composed-tags" aria-describedby="composed-tags-count">
Tags
</button>
<output id="composed-tags-count" class="position-absolute top-0 start-100 translate-middle badge rounded-pill text-bg-danger dropdown-multiselect-count" data-controls="composed-tags" for="composed-tag-html composed-tag-css composed-tag-js">1<span class="visually-hidden"> selected</span></output>
<ul class="dropdown-menu" popover="auto" id="composed-tags">
<li>
<label class="dropdown-item" for="composed-tag-html">
<input class="form-check-input" type="checkbox" value="html" id="composed-tag-html">
HTML
</label>
</li>
<li>
<label class="dropdown-item" for="composed-tag-css">
<input class="form-check-input" type="checkbox" value="css" id="composed-tag-css" checked>
CSS
</label>
</li>
<li>
<label class="dropdown-item" for="composed-tag-js">
<input class="form-check-input" type="checkbox" value="js" id="composed-tag-js">
JavaScript
</label>
</li>
</ul>
</div> Filter drawer
The same <output data-controls> sits beside the
toggle. The checkboxes live in an offcanvas, so count is not a
dropdown feature.
Offcanvas filters
Open the drawer and toggle filters. The badge updates while the drawer is open or closed.
<script type="module" src="./node_modules/bootstrap-html/src/behaviors/count.js"></script>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-primary" command="show-modal" commandfor="composed-filter" aria-describedby="composed-filter-count">
Filters
</button>
<output id="composed-filter-count" class="badge rounded-pill text-bg-secondary" data-controls="composed-filter-list" for="composed-filter-sale composed-filter-new composed-filter-stock">0<span class="visually-hidden"> selected</span></output>
</div>
<dialog class="offcanvas offcanvas-end" id="composed-filter" closedby="any" aria-labelledby="composed-filter-label">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="composed-filter-label">
Filters
</h5>
<button type="button" class="btn-close" command="close" commandfor="composed-filter" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<div id="composed-filter-list">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="sale" id="composed-filter-sale">
<label class="form-check-label" for="composed-filter-sale">On sale</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="new" id="composed-filter-new">
<label class="form-check-label" for="composed-filter-new">New</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="stock" id="composed-filter-stock">
<label class="form-check-label" for="composed-filter-stock">In stock</label>
</div>
</div>
</div>
</dialog> Lightbox
A <dialog class="modal"> with an
<img>. command="show-modal" opens
it. There is no script.
Photo dialog
Open the dialog. Escape or the close button dismisses it.
<button type="button" class="btn btn-primary" command="show-modal" commandfor="composed-lightbox">
View photo
</button>
<dialog class="modal" closedby="any" id="composed-lightbox" aria-labelledby="composed-lightbox-label">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="composed-lightbox-label">Lake and mountains</h1>
<button type="button" class="btn-close" command="close" commandfor="composed-lightbox" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<img class="img-fluid w-100" width="800" height="450" alt="Lake and mountains" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 450'%3E%3Crect fill='%236c757d' width='800' height='450'/%3E%3Ctext fill='white' font-family='system-ui,sans-serif' font-size='28' x='50%25' y='50%25' text-anchor='middle' dy='.3em'%3ELake and mountains%3C/text%3E%3C/svg%3E">
</div>
</div>
</div>
</dialog> Confirm then toast
<form method="dialog"> sets
returnValue. A small page script shows the toast only
after Delete — the same pattern as the meter playground. Cancel
does nothing. This is not a new behavior.
Delete file
Confirm to show the toast. Cancel closes the dialog only.
<script type="module" src="./node_modules/bootstrap-html/src/behaviors/toast.js"></script>
<button type="button" class="btn btn-danger" command="show-modal" commandfor="composed-delete">
Delete file
</button>
<dialog class="modal" closedby="any" id="composed-delete" aria-labelledby="composed-delete-label">
<form method="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="composed-delete-label">Delete file?</h1>
<button type="submit" class="btn-close" value="cancel" aria-label="Close"></button>
</div>
<div class="modal-body">
This cannot be undone. Cancel leaves the file. Delete shows a toast.
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-secondary" value="cancel">Cancel</button>
<button type="submit" class="btn btn-danger" value="delete">Delete</button>
</div>
</div>
</div>
</form>
</dialog>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<pe-toast class="toast" id="composed-deleted" popover="manual" data-delay="4000" role="status" aria-live="polite" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto">Bootstrap HTML</strong>
<button type="button" class="btn-close" command="hide-popover" commandfor="composed-deleted" aria-label="Close"></button>
</div>
<div class="toast-body">File deleted.</div>
</pe-toast>
</div> Command palette
A <dialog>, a <search>
landmark, and a static list of links.
<datalist> is the typeahead. Do not filter the
list with script.
Jump to
Open the dialog. Type to see suggestions. The links stay visible.
<button type="button" class="btn btn-primary" command="show-modal" commandfor="composed-palette">
Jump to
</button>
<dialog class="modal" closedby="any" id="composed-palette" aria-labelledby="composed-palette-label">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="composed-palette-label">Jump to</h1>
<button type="button" class="btn-close" command="close" commandfor="composed-palette" aria-label="Close"></button>
</div>
<div class="modal-body">
<search class="mb-3">
<label class="form-label" for="composed-palette-q">Search</label>
<input class="form-control" type="search" id="composed-palette-q" name="q" list="composed-palette-list" placeholder="Accordion, modal, …">
</search>
<datalist id="composed-palette-list">
<option value="Accordion"></option>
<option value="Buttons"></option>
<option value="Modal"></option>
<option value="Output"></option>
</datalist>
<ul class="list-unstyled mb-0">
<li><a href="/bootstrap-html/components/accordion">Accordion</a></li>
<li><a href="/bootstrap-html/components/buttons">Buttons</a></li>
<li><a href="/bootstrap-html/components/modal">Modal</a></li>
<li><a href="/bootstrap-html/forms/output">Output</a></li>
</ul>
</div>
</div>
</div>
</dialog> Price toggle
Radio .btn-check from
Buttons.
:has(:checked) switches the visible price. No script.
Monthly or yearly
Choose yearly. The price switches in CSS. Without :has() the monthly price stays.
$9 / month $90 / year
<div class="price-toggle">
<fieldset class="mb-3">
<legend class="visually-hidden">Billing</legend>
<input type="radio" class="btn-check" name="composed-plan" id="composed-plan-monthly" value="monthly" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="composed-plan-monthly">Monthly</label>
<input type="radio" class="btn-check" name="composed-plan" id="composed-plan-yearly" value="yearly" autocomplete="off">
<label class="btn btn-outline-primary" for="composed-plan-yearly">Yearly</label>
</fieldset>
<p class="fs-3 mb-0">
<span class="price-monthly">$9<span class="fs-6 text-body-secondary"> / month</span></span>
<span class="price-yearly">$90<span class="fs-6 text-body-secondary"> / year</span></span>
</p>
</div> Interest preview
Experimental. interestfor plus
popover="hint" with more than a one-line tooltip.
The polyfill covers hover and focus, not
popover="hint".
aria-description stays the no-script fallback. Do not
announce the preview from a live region.
Link card
Hover or focus Progress. The hint is a short card. The description remains if hint is missing.
Hover or focus Progress for a card preview. The description stays if hint is missing.
Native progress and meter. Omit value for the indeterminate stripe.
Open page<p class="mb-0">
Hover or focus
<a href="/bootstrap-html/components/progress" class="link-offset-2" interestfor="composed-preview" aria-description="Progress — native progress and meter.">Progress</a>
for a card preview. The description stays if hint is missing.
</p>
<div class="tooltip bs-tooltip-top" popover="hint" id="composed-preview">
<div class="tooltip-arrow"></div>
<div class="tooltip-inner text-start">
<strong>Progress</strong>
<p class="small mb-1">Native progress and meter. Omit value for the indeterminate stripe.</p>
<a class="link-light" href="/bootstrap-html/components/progress">Open page</a>
</div>
</div> Accessibility
Context-menu items are buttons (or links), not role="menuitem". Escape and light-dismiss come from popover="auto". Right-click (and the contextmenu key) is the surface gesture. A visible Actions button is a separate left-click invoker; the menu must tether to that button, not the whole host.
Nested details in .nav-tree are disclosures. Do not add aria-expanded on the summary. A catalog of links can sit in a <nav>.
The toast countdown bar is decorative. role="status" or role="alert" on the toast is still the announcement. Hover and focus pause data-delay so the message can be read.
The tab underline follows aria-selected. It is not a substitute for those roles.
Label a .progress-ring with <label for> or aria-label. Center text is optional decoration.
A purely visual .scroll-progress takes aria-hidden="true".
The checked count is an <output data-controls> status live region, not a pe-* element. Keep it out of the toggle button so the count is not announced twice. On a dropdown, aria-describedby on the button points at that output. Keep menu checkboxes in the closed popover so they are not tab stops. The contract lives on Output.
A lightbox image needs an alt that names the photo. The dialog title can repeat it.
Confirm-then-toast: the dialog submit is the decision; the toast is the status. Do not announce the toast if the user cancelled.
An interest preview is not a live region. aria-description on the trigger is the no-script name. The hint popover is extra, not a second announcement.
Browser support
| No script | Polyfill | No fallback |
|---|---|---|
Drawer, context menu, and the progress ring work without a component script. The multi-select menu opens without script. <output data-controls> needs src/behaviors/count.js to stay in sync, and a closed popover cannot increment a CSS counter. command still uses the invoker polyfill when missing. | The same command polyfill as dropdown and offcanvas. | Context-menu position-area and the tab underline need CSS Anchor Positioning. The shrinking header and scroll bar need animation-timeline: scroll() (not in Firefox). The ring’s attr() into --bs-progress-value is Chrome 133+; set the custom property in markup elsewhere. No scroll-listener and no pointer-coordinate script. src/behaviors/count.js is the one optional script on this page that is not a pe-* behavior. |