A drag handle is the affordance that makes something legible as draggable: the grabber on a bottom sheet, the bar in the gutter between two panes, the grip on a row that can be reordered.
Which element you write is the semantic. A
<span class="drag-handle" aria-hidden="true"> is
decoration — the usual case. A
<button class="drag-handle"> with a name is a
control, and you write that one only when something is genuinely
wired to it. Nothing else about the markup changes.
<span class="drag-handle" aria-hidden="true"></span>
Dragging is not included
This component is a bar. Nothing in it starts a drag, moves anything,
or reorders anything. The one exception is not the handle's doing: a
handle written as a <button> inside a
bottom sheet dismisses
that sheet, because the sheet's own behaviour claims it. Everywhere
else the bar is inert, and that is worth being blunt about.
Provide keyboard operation and a single-pointer alternative without dragging. Clickable "Move up" / "Move down" controls in a menu, or a position input, can serve both paths. Arrow keys alone cover only keyboard operation. WCAG 2.2 SC 2.5.7 requires the pointer alternative unless dragging is essential or the unmodified user agent owns the functionality. Test each path.
ExpressiveCSS ships no reordering behaviour, so there is nothing here that supplies that path for you. The handle makes the gesture discoverable to people who can use it; the rest is yours.
Decorative or control
A handle draws a bar and contains no text, so it reports nothing to a screen reader. Left exposed it arrives in the reading order as an unlabelled blank. Hide it:
<span class="drag-handle" aria-hidden="true"></span>
Make it a <button> only when activating it does
something — and then it needs a name, because there is no text inside
it to be named by. The interactive states (hover, focus ring, and the
swell to 12×52dp while it is held) are scoped to the button
spelling for exactly this reason: a decorative handle must not light
up under a pointer that merely crosses it.
<button type="button" class="drag-handle" aria-label="Resize the editor"></button>
What you must not write is a hidden button. aria-hidden
takes an element out of the accessibility tree without taking it out
of the tab order, so focus lands on something that, as far as
assistive technology is concerned, is not there.
Between two panes
This is the shape Material tokenised: a vertical bar in the gutter of a two-column layout, 4×48dp inside a 24dp hit target. The layout is yours — a flex or grid row of your own, or a pane layout. The handle is whatever sits between the columns; it brings its own width and needs no wrapper class.
<section aria-label="Editor">…</section>
<span class="drag-handle" aria-hidden="true"></span>
<section aria-label="Preview">…</section>
The handle is cursor: grab by default. A splitter that
resizes rather than moves reads better as
cursor: col-resize — one declaration, and the component
does not guess which of the two you meant.
On a bottom sheet
A bottom sheet paints its
grabber with ::before and needs no markup at all. Write a
handle yourself only when you want a real element in that slot — and
it takes the sheet's shape rather than the vertical one, because
Material gives the sheet's grabber its own size and colour
(32×4dp at 40% on-surface-variant). One class,
whichever host it lands in.
<dialog class="bottom-sheet" aria-labelledby="sheet-title">
<span class="drag-handle" aria-hidden="true"></span>
<h2 id="sheet-title">Share</h2>
…
<form method="dialog"><button type="submit">Close</button></form>
</dialog>
A decorative handle needs a separate non-drag dismiss path, such as
the close button above. Native Escape behavior depends on the opening
mode, browser, and closedby policy; Escape is not a pointer
alternative. Scrim dismissal also depends on the installed shared
runtime and closedby policy. Test it before relying on it.
Write a <button> there instead when you want a
visible dismiss control. It is the one place a drag handle is wired to
anything: the sheet closes on click, tap, Enter, or
Space. A drag that snaps back does not also dismiss because
the runtime distinguishes the ending click from a tap.
<dialog class="bottom-sheet" aria-labelledby="share-title">
<button type="button" class="drag-handle" aria-label="Dismiss"></button>
<h2 id="share-title">Share</h2>
…
</dialog>
.handle is the pre-1.0 spelling of the same slot and
still works, decorative or wired.
Tokens
Set these on the handle itself, or in a rule of your own that picks out the handles you want to change. They are declared on the component, so a value set on an ancestor element is shadowed by the handle's own default rather than inherited.
| Token | Default |
|---|---|
--md-comp-drag-handle-container-width | 24px |
--md-comp-drag-handle-width | 4px |
--md-comp-drag-handle-height | 48px |
--md-comp-drag-handle-shape | 9999px |
--md-comp-drag-handle-color | var(--md-sys-color-outline) |
--md-comp-drag-handle-pressed-width | 12px |
--md-comp-drag-handle-pressed-height | 52px |
--md-comp-drag-handle-pressed-shape | 12px |
--md-comp-drag-handle-pressed-color | var(--md-sys-color-on-surface) |
--md-comp-drag-handle-state-layer-color | var(--md-sys-color-inverse-on-surface) |
The container is sized to the pressed bar rather than the
resting one, so holding the handle swells the bar without moving
anything beside it. Inside a bottom sheet none of these apply — the
sheet's own --md-comp-bottom-sheet-drag-handle-color
does.