Sheets

A surface docked to an edge of the screen, holding content secondary to what is behind it. Dock it to the bottom for a bottom sheet, or to a side for a side sheet.

Usage

A sheet is a <dialog>. A modal one takes focus, keeps the page behind it unreachable for as long as it is open, and gives focus back when it closes, all from the browser rather than from script. Clicking the scrim or pressing Escape closes it.

Give the sheet an id and point a trigger at it with command="show-modal" and commandfor. command="close" closes it again. No script and no state of your own, and it works before the page has hydrated.

Bottom sheet

<script lang="ts">
	import { Button, IconButton, Item, Sheet } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'
</script>

<Button command="show-modal" commandfor="bottom-sheet">Open bottom sheet</Button>
<Sheet id="bottom-sheet" headline="Bottom sheet">
	{#snippet action()}
		<IconButton title="Close" command="close" commandfor="bottom-sheet">
			<Icon>close</Icon>
		</IconButton>
	{/snippet}
	<Item type="button" command="close" commandfor="bottom-sheet">Share</Item>
	<Item type="button" command="close" commandfor="bottom-sheet">Add to favourites</Item>
	<Item type="button" command="close" commandfor="bottom-sheet">Delete</Item>
</Sheet>

Placement

placement picks the edge. bottom and top span the width; start and end run the full height, which is M3's side sheet. The drag handle is a bottom sheet affordance, so it is only drawn there.

Side sheet

Filters, details, or anything secondary to the page behind it.

<script lang="ts">
	import { Button, IconButton, Sheet } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'
</script>

<Button command="show-modal" commandfor="side-sheet">Open side sheet</Button>
<Sheet id="side-sheet" placement="end" headline="Side sheet">
	{#snippet action()}
		<IconButton title="Close" command="close" commandfor="side-sheet">
			<Icon>close</Icon>
		</IconButton>
	{/snippet}
	<p>Filters, details, or anything secondary to the page behind it.</p>
</Sheet>

Heights

A bottom sheet opens at most half the window high. Selecting the drag handle, with a click or with Space or Enter, raises a taller sheet to its full height and lowers it again; a sheet that already shows everything closes. The handle can also be dragged: up to raise the sheet, down to lower or close it, settling on the nearest height. Raised, the sheet keeps 72dp free at the top, or 56dp in a window wider than 640dp, where it also keeps 56dp at the sides. bind:expanded follows and sets the height.

Places nearby

<script lang="ts">
	import { Button, Item, Sheet } from 'noph-ui'

	const places = Array.from({ length: 24 }, (_, index) => `Place ${index + 1}`)
</script>

<Button command="show-modal" commandfor="heights-sheet">Open places</Button>
<Sheet id="heights-sheet" headline="Places nearby">
	{#each places as place (place)}
		<Item type="button" command="close" commandfor="heights-sheet">{place}</Item>
	{/each}
</Sheet>

Side sheet

A side sheet can lead its header with a back button through leading and line up its actions along the bottom through actions. detached holds it 16dp from the window's edges with every corner rounded.

Filters

<script lang="ts">
	import { Button, IconButton, Sheet, TextField } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'
</script>

<Button command="show-modal" commandfor="filter-sheet">Open filters</Button>
<Sheet id="filter-sheet" placement="end" detached headline="Filters">
	{#snippet leading()}
		<IconButton title="Back" command="close" commandfor="filter-sheet">
			<Icon>arrow_back</Icon>
		</IconButton>
	{/snippet}
	{#snippet action()}
		<IconButton title="Close" command="close" commandfor="filter-sheet">
			<Icon>close</Icon>
		</IconButton>
	{/snippet}
	<TextField label="Keyword" />
	{#snippet actions()}
		<Button command="close" commandfor="filter-sheet">Apply</Button>
		<Button variant="outlined" command="close" commandfor="filter-sheet">Cancel</Button>
	{/snippet}
</Sheet>

Standard

modal=false makes a standard sheet: it leaves the rest of the page usable, with no scrim and no focus trap. Close it yourself, since there is no light dismiss. A standard side sheet is part of the layout: place it in a row beside the content, which shrinks to make room as the sheet opens.

This is the one sheet a trigger cannot open on its own. The invoker commands cover a modal dialog only, so there is no counterpart to show-modal for a standard one: bind open, or call show() and close() on the component. command="close" still closes it.

The content makes room for the sheet and stays interactive.

Standard

A standard side sheet sits beside the content.

<script lang="ts">
	import { Button, IconButton, Sheet } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'

	let open = $state(false)
</script>

<div style="display:flex;width:100%;min-height:16rem;overflow:hidden">
	<div style="flex:1;min-width:0;padding:1rem">
		<Button onclick={() => (open = !open)}>{open ? 'Close' : 'Open'} standard sheet</Button>
		<p>The content makes room for the sheet and stays interactive.</p>
	</div>
	<Sheet bind:open modal={false} placement="end" headline="Standard">
		{#snippet action()}
			<IconButton title="Close" onclick={() => (open = false)}>
				<Icon>close</Icon>
			</IconButton>
		{/snippet}
		<p>A standard side sheet sits beside the content.</p>
	</Sheet>
</div>

Theming

Custom propertyDescription
--np-sheet-container-colorBackground.
--np-sheet-shapeCorner radius on the exposed edges.
--np-sheet-max-widthWidest a bottom sheet gets before it centers. Defaults to 40rem.
--np-sheet-sizeHeight of a bottom or top sheet, width of a side sheet.
--np-sheet-handle-colorThe drag handle.
--np-sheet-elevationShadow.

Accessibility

The sheet is a native <dialog>. A modal sheet opens with showModal, so the browser keeps focus inside it, marks the rest of the page inert and closes it on Escape or a click on the scrim. A standard sheet stays part of the page, and focus moves in and out of it as usual.

The headline names the sheet through aria-labelledby and renders as a level two heading, which headlineLevel moves where your page outline needs it. A sheet without a headline should carry aria-label. The drag handle is a button named by handleLabel, "Drag handle" by default, whose aria-expanded says whether the sheet is raised. It is the only part a pointer drags, so the content keeps scrolling as usual.

API

Renders a <dialog> and takes its attributes. bind:element gives you that element, and show() and close() open and close it from outside.

AttributeTypeDefaultDescription
openbooleanfalseBindable. Whether the sheet is showing.
modalbooleantrueBlocks the page behind it and adds a scrim, or sits alongside it.
placement'bottom' | 'top' | 'start' | 'end''bottom'Which edge it docks to.
handlebooleantrueThe drag handle. Drawn on a bottom sheet only.
handleLabelstring'Drag handle'Names the drag handle.
expandedbooleanfalseWhether a bottom sheet is raised to its full height. Bindable.
detachedbooleanfalseHolds a side sheet 16dp from the window's edges, rounded all round.
leadingSnippet | undefinedundefinedBefore the headline, such as a back button.
headlinestring | undefinedundefinedNames the sheet, and is what assistive technology announces.
headlineLevel1 | 2 | 3 | 4 | 5 | 62Heading level the headline renders as. Move it where your page outline needs it.
actionSnippet | undefinedundefinedTrailing action in the header, usually a close button.
actionsSnippet | undefinedundefinedActions along the bottom of the sheet.