Drawer

This is a panel at an edge of the viewport. It behaves like a modal dialog: the focus stays in the panel and the page does not scroll. The Escape key and a press outside it close it. It also has what a sheet needs on a telephone. It has a drag that follows the finger, snap points, a stack, and a knowledge of the keyboard.

Anatomy

All of the parts are in Drawer.Root. The backdrop and the panel are in Drawer.Portal.

<script>
	import { Drawer } from '@human-kit/ui';
</script>

<Drawer.Root side="bottom">
	<Drawer.Trigger>Open</Drawer.Trigger>
	<Drawer.Portal>
		<Drawer.Overlay />
		<Drawer.Content>
			<Drawer.Title>Title</Drawer.Title>
			<Drawer.Description>Supporting text.</Drawer.Description>
			<Drawer.Body>...</Drawer.Body>
			<Drawer.Close>Close</Drawer.Close>
		</Drawer.Content>
	</Drawer.Portal>
</Drawer.Root>

Drawer.Viewport is an optional layer for the position, between the portal and the panel. Without it, the panel goes to its own edge, and the examples on this page use that behavior. Add a viewport when you must align the panel on the other axis.

Styles for the movement

The drawer never writes a transform of its own. It publishes the numbers, and your CSS decides what to do with them. Each other component in this library also leaves the appearance to the page.

PropertyMeaning
--drawer-swipe-movement-x / -yThe drag distance in px, with a sign. Use it directly in a translation.
--drawer-swipe-progressFrom 0 to 1, toward dismissal. Also on Drawer.Overlay.
--drawer-swipe-strength1 for a release without speed, down to 0.1 for a fast flick.
--drawer-overdragThe pull past the open position, in px, with resistance. At other times, 0.
--drawer-snap-point-offsetThe translation of the current snap point, in px.
--drawer-width / --drawer-heightThe measured size of the panel.
--drawer-frontmost-heightThe extent of the drawer at the front of the stack.
--nested-drawersThe distance from the front of the stack. 0 is the front.
--drawer-keyboard-insetThe height that the software keyboard covers. Set only while the keyboard is up.

The rest position is the snap point plus the live drag. Keep the closed offset in a third variable. Thus one rule covers the open state, the closed state, and the drag:

<Drawer.Content
	class="transition-[translate] duration-300
	       data-[state=closed]:[--drawer-closed:100%]
	       data-[starting-style]:[--drawer-closed:100%]
	       data-[swiping]:transition-none"
	style="translate: 0 calc(var(--drawer-snap-point-offset) + var(--drawer-swipe-movement-y) + var(--drawer-closed, 0px));"
/>

The panel has data-starting-style for the first painted frame after it goes into the DOM, and for no other frame. A transition needs a start value, and a node that goes directly to its final position has none. Without this rule, the drawer has an exit animation but no entrance animation. Give it the same value as the closed state.

The data-swiping rule removes the transition. This is what makes the panel follow the finger, and not stay one frame behind it.

Dismissal, not movement

--drawer-swipe-progress stays at 0 while the panel moves between the snap points. It becomes larger only when the panel goes past the last snap point and is truly on its way out. A backdrop that faded for each drag looked incorrect in the exact case that it must help. A drag from one snap point to a different snap point made the page brighter. At the release, the page became dark again, for a drawer that did not go away.

What can start a drag

The user can drag all of the panel, with two exceptions:

  • Drawer.Body, with a mouse. With a pointer that can select text, a drag and a text selection are the same gesture. Thus, in the content region, the mouse selects the text and the drawer does not move. The drawer does not move and come back — it ignores the drag. A finger still drags the panel. On a touch screen there is no drag to select text. On a telephone, the user must not lose the gesture on most of the sheet.

    Put your content in Drawer.Body, and keep the grab bar outside it. Thus a mouse still has an obvious surface to drag the panel by, and the drawer does what that surface promises.

  • An element with data-hk-swipe-ignore. Put this attribute on a slider, a carousel, or a color picker — on any control with a drag of its own. With no value, the attribute stops the drag for each input type. With data-hk-swipe-ignore="mouse", it stops the drag for a mouse only, and this is what Drawer.Body uses.

A drag that starts in a region that scrolls belongs to that region until the region gets to its limit. Thus a bottom sheet with a body that scrolls starts to move only when the body is at the top.

Pull past the open position

If the user drags a drawer more open than its limit, the panel becomes longer. It follows the finger with much resistance, and it stops at 40 px, whatever the distance of the finger. The component publishes this distance in --drawer-overdrag, in px.

A panel that moves off its edge would usually show the page in the strip behind it. Thus, during the pull, the drawer paints that strip with its own background. You get this with no other code, but the panel must have a background of its own.

Sides

The side prop sets the edge of the panel. With it, the prop also sets the axis of the movement and the direction that dismisses the panel.

Snap points

The snapPoints prop gives the panel rest positions between the open position and the closed position. A value from 0 to 1 is a fraction of the viewport. A number more than 1 is a number of pixels. A string is any CSS length or percentage.

At the release, the panel goes to the point that the flick moved toward. It does not go to the nearest point at the moment of the release. A short fast drag means "go further". The snapToSequentialPoints prop holds the panel to one step at a time, for the cases where a jump would confuse the user.

Make the panel as large as the largest snap point. The smaller snap points then move it down from there.

Nested drawers

Put a Drawer.Root in the content of a different drawer. The drawer behind gets data-nested-drawer-open and --nested-drawers, thus it can move back. The Escape key removes one layer of the stack at a time.

Only the drawer at the back of the stack paints a backdrop. The overlay of each drawer above it gets data-nested and paints nothing. Each root has its own overlay. One backdrop for each layer made the page darker one time for each layer. It also made the drawer below dark.

Drawer.Indent contains the UI of your application, and it publishes --drawer-indent-progress. That number goes back toward 0 while the user drags the front drawer away. Thus the page comes forward with the gesture, and it does not move suddenly when the drawer closes. Drawer.IndentBackground paints the surface behind it.

Non-modal mode

With modal={false}, the focus does not stay in the panel, the page scrolls, and assistive technology reads the rest of the page. The user can scroll the page behind, click it, and go through it with the Tab key while the panel stays open. Use this mode for a music player, a rail of filters, or an inspector.

The page stays scrollable and clickable underneath.

In this mode, do not use Drawer.Overlay. A backdrop says "obey me first", and this is the opposite of the function of a non-modal panel. Use shouldCloseOnInteractOutside={false} with it. Thus the page that the user operates does not close the panel that the user opened for that work.

modal="trap-focus" is between the two modes. The focus stays in the panel, but the page scrolls and assistive technology reads it.

Swipe to open

Drawer.SwipeArea is a strip at the edge of the viewport. A drag from the edge to the center opens the drawer, and the panel follows the finger. Put this part outside Drawer.Portal, because the part must exist while the drawer is closed.

On a touch screen, drag in from the left edge.

Use a Drawer.Trigger with it. A swipe has no keyboard equivalent and no screen reader equivalent. Thus some people cannot open a drawer that only a swipe opens.

Forms and the software keyboard

Put Drawer.Root in a Drawer.VirtualKeyboardProvider when the panel contains fields. The keyboard makes the visual viewport smaller, but it does not change the layout viewport. Thus CSS does not know about the keyboard, and a bottom sheet goes below the keys.

Keep the header and the footer outside Drawer.Body, and move the footer by var(--drawer-keyboard-inset, 0px). The default value is necessary: the variable exists only while the keyboard is up.

Triggers in other positions

Sometimes the buttons that open the drawer are in many positions. For example, each row of a table has one button, and each button opens the same drawer with a different record. Then createDrawerHandle() makes the object between them. Each trigger writes into the object, one root reads from it, and the value of the trigger arrives as the payload of the root.

<script>
	import { Drawer, createDrawerHandle } from '@human-kit/ui';

	const profile = createDrawerHandle();
</script>

{#each users as user}
	<Drawer.Trigger handle={profile} payload={user}>{user.name}</Drawer.Trigger>
{/each}

<Drawer.Root handle={profile}>
	{#snippet children({ payload })}...{/snippet}
</Drawer.Root>

At the close, the focus goes back to the trigger that opened the drawer, and not to the trigger that went into the DOM last.

Controlled state

Bind open to control the drawer from your own code. Then a trigger is optional. Use defaultOpen when the component controls the state. The onOpenChange prop reports each change. Set controlledOpen to stop the component from writing the value back. Thus the parent can refuse a change: the parent does not send the new value down. The snapPoint prop has the same behavior.

Usage guidelines

  • Always give the drawer a Drawer.Title. A role="dialog" element gets its name from aria-labelledby, and not from the text in it.
  • Always give a dismissible drawer a Drawer.Close. Thus the user can close it without a gesture.
  • Put the region that scrolls in Drawer.Body. Thus a scroll that gets to its end does not continue on the page behind.
  • modal="trap-focus" keeps the focus in the panel, but the page still scrolls. modal={false} stops the two behaviors, for a panel that the user operates with the rest of the page.
  • Set dismissible={false} for a drawer that the user must close deliberately. The panel still moves with the finger, and then it comes back. A gesture that does nothing looks like a fault.

Accessibility

  • Drawer.Content has role="dialog". While the drawer is open and modal is true, it also has aria-modal="true".
  • Drawer.Title and Drawer.Description set aria-labelledby and aria-describedby automatically.
  • The focus stays in the open drawer. Assistive technology does not read the content outside the drawer.
  • The Escape key closes the top layer that is dismissible. Thus a popover that opened in the drawer closes first, and the next press closes the drawer.
  • While a modal drawer is open, the page does not scroll. The component pins the body, which also stops the touch scroll in iOS Safari.
  • With prefers-reduced-motion, the entrance and the exit become one frame, because the component measures the presence from your CSS and not from a fixed time.

API reference

Root

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Trigger

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Data attribute Description
data-drawer-trigger Identifies the trigger button.

Portal

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Overlay

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Data attribute Description
data-drawer-overlay Identifies the backdrop element.
data-entering Present while the enter animation operates.
data-exiting Present while the exit animation operates.
data-nested Present when a drawer is already open behind this one. This backdrop then paints nothing. Only the backdrop at the back of the stack paints.
data-side The edge the drawer is anchored to: `top`, `right`, `bottom` or `left`.
data-starting-style Present for the first painted frame after the element goes into the DOM. Thus a CSS transition has a start value.
data-state Either `"open"` or `"closed"`. It becomes closed when the exit starts.
data-swiping Present while the user drags the panel. Remove your transitions under it.

Viewport

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Data attribute Description
data-drawer-viewport Identifies the positioning layer.
data-entering Present while the enter animation operates.
data-exiting Present while the exit animation operates.
data-side The edge the drawer is anchored to: `top`, `right`, `bottom` or `left`.
data-state Either `"open"` or `"closed"`. It becomes closed when the exit starts.

Content

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Data attribute Description
data-drawer-bleed Identifies the strip outside the anchored edge. It covers the page while the panel goes past the open position.
data-drawer-content Identifies the panel.
data-entering Present while the enter animation operates.
data-exiting Present while the exit animation operates.
data-expanded Present at the snap point that is the most open.
data-nested-drawer-open Present while a drawer that opened after this one is open.
data-nested-drawer-swiping Present while the user drags that nested drawer.
data-side The edge the drawer is anchored to: `top`, `right`, `bottom` or `left`.
data-starting-style Present for the first painted frame after the element goes into the DOM. Thus a CSS transition has a start value.
data-state Either `"open"` or `"closed"`. It becomes closed when the exit starts.
data-swipe-direction Direction of the gesture: `up`, `down`, `left` or `right`.
data-swipe-dismiss Present from the moment a swipe dismisses the drawer, until the exit is complete.
data-swiping Present while the user drags the panel. Remove your transitions under it.

Body

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Data attribute Description
data-drawer-body Identifies the content region. The user cannot drag the drawer from inside it.

Title

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Data attribute Description
data-drawer-title Identifies the heading that names the drawer.

Description

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Data attribute Description
data-drawer-description Identifies the supporting description.

Close

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Data attribute Description
data-drawer-close Identifies a button that closes the drawer.

SwipeArea

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Data attribute Description
data-axis The axis of the open gesture: `x` or `y`.
data-disabled Present when the open gesture is off.
data-drawer-swipe-area Identifies the edge strip that opens the drawer.
data-state Either `"open"` or `"closed"`. It becomes closed when the exit starts.
data-swipe-direction Direction of the gesture: `up`, `down`, `left` or `right`.
data-swiping Present while the user drags the panel. Remove your transitions under it.

Indent

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Data attribute Description
data-drawer-indent Identifies the wrapper around the app UI.
data-state Either `"open"` or `"closed"`, following whether any drawer is open.
data-swiping Present while the user drags a drawer. Remove your transitions under it.

IndentBackground

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.

Data attribute Description
data-drawer-indent-background Identifies the surface behind the application UI when that UI moves back.
data-state Either `"open"` or `"closed"`, following whether any drawer is open.

VirtualKeyboardProvider

Prop Type Default

* required. Native HTML attributes of the underlying element are also accepted.