<rearrange-elemental>
Wrap an <ol>, a <ul> or a <table> and its items can be rearranged by hand. You write the
markup; it writes the buttons. Wrap several named lists and it is a board: the same buttons, plus
one for the column on each side.
There is no APG pattern for rearranging. The closest thing the APG has is its
rearrangeable listbox example,
and what is taken from it is the part that is not the listbox: buttons that move an item,
aria-keyshortcuts naming the fast way to press them, focus staying on the button that moved,
and a live region confirming where the thing landed. The listbox roles are deliberately left
behind β a list of things you are rearranging is a list, not a set of options you are choosing
between, and role="listbox" would tell the reader they are picking one.
<rearrange-elemental drag>
<ol>
<li><span class="rearrange-elemental-handle" data-rearrange-handle aria-hidden="true"></span>Boil the kettle</li>
<li><span class="rearrange-elemental-handle" data-rearrange-handle aria-hidden="true"></span>Warm the pot</li>
<li><span class="rearrange-elemental-handle" data-rearrange-handle aria-hidden="true"></span>Measure the leaves</li>
<li><span class="rearrange-elemental-handle" data-rearrange-handle aria-hidden="true"></span>Pour, and wait</li>
</ol>
</rearrange-elemental>
/* the arrows, the grip and the lift are the theme's; every line here is the page's */
ol { margin: 0; padding: 0; list-style: none; }
li { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.6rem; border: 1px solid color-mix(in srgb, CanvasText 15%, transparent); border-radius: 0.4rem; }
li + li { margin-block-start: 0.35rem; }
/* the grip is the item's first child, so the flex row puts it at the front on its own; the
controls are the last, and this is what pushes them to the far end */
[data-rearrange-controls] { margin-inline-start: auto; }Press the arrows, or hold Alt and use β β from anywhere in the row. Drag by the grip with a mouse or a finger, and Esc mid-drag puts the item back. The button at the top of the list keeps its name and its focus β it just does nothing.
The buttons are the element; dragging is the option
Every pointer-drag library gets this the other way round and bolts a keyboard mode on the side. WCAG 2.2 SC 2.5.7 Dragging Movements (AA) names this exact case and asks for the opposite β "a sortable list of elements may, after tapping or clicking on a list element, provide adjacent controls for moving the element up or down in the list by simply tapping or clicking".
Written this way round, the criterion is met by construction: the button path cannot be switched
off, because drag only ever adds a second way to do what the buttons already do. Leave drag
off and you have a list that rearranges with no dragging in it at all β which is also the version
that works on a switch, a head pointer and a screen reader's touch gestures.
The markup
A list. That is the whole of it:
<rearrange-elemental>
<ul>
<li>Bananas</li>
<li>Kiwi</li>
</ul>
</rearrange-elemental>
- The container is a direct child: an
<ol>, a<ul>, a<menu>, or a<table>β whose first<tbody>is the container and whose<tr>s are the items. The items are the container's own children, so a nested list inside an item is not part of this one. The first<tbody>only: a table with several is using them to group, and moving a row between groups would be rearranging the grouping away. More than one container is a board, and each of them then needs a name. data-labelon an item is what the buttons and the announcement call it. Without one the item's own text is used, collapsed and cut at 80 characters on a word boundary β which is fine forBananasand not for a paragraph. A row is named by its<th scope="row">, or by its first cell when it has none: every cell run together is the whole record read out before the word "up", once per button and again on every move.data-rearrange-cellon a cell says which column the controls belong in. Without one they go in the row's last cell, which is the choice that needs no extra<th>in the header.data-rearrange-handleinside an item is your own grip, used instead of the one the element would write. It is your markup: give itaria-hidden="true"if it is a glyph, and the element leaves it alone whendraggoes away. It is also the only way to a grip somewhere the controls are not β the front of the item, rather than beside the buttons at the end of it. The samples on this page borrowclass="rearrange-elemental-handle"so the theme paints their grip; a page with a look of its own writes one.
What it writes
Into every item, after whatever was already there:
<span class="rearrange-elemental-controls" data-rearrange-controls>
<span class="rearrange-elemental-handle" data-rearrange-handle data-rearrange-own aria-hidden="true"></span>
<button type="button" class="rearrange-elemental-move" data-move="up" aria-keyshortcuts="Alt+ArrowUp" aria-disabled="true">
<span class="rearrange-elemental-label">Move Bananas up</span>
</button>
<button type="button" class="rearrange-elemental-move" data-move="down" aria-keyshortcuts="Alt+ArrowDown">
<span class="rearrange-elemental-label">Move Bananas down</span>
</button>
</span>
and once, at the end of the element:
<p class="rearrange-elemental-status" role="status"></p>
On a board, the same box also carries the buttons that cross β prev before the up button and
next after the down one, so the row reads the way the board looks:
<button type="button" class="rearrange-elemental-move" data-move="prev" aria-keyshortcuts="Alt+Shift+ArrowLeft">
<span class="rearrange-elemental-label">Move Bananas to To do</span>
</button>
The handle is only there when drag is set, and the crossing buttons only on a board. Nothing
else is added, nothing is wrapped, and no role is written onto your lists or your items.
The button's name is its own visible text. An icon with an aria-label over it would be a
name written nowhere on the screen, which fails
WCAG 2.2 SC 2.5.3 Label in Name
and leaves a reader speaking to their computer with nothing to say. The theme clips the span and
draws an arrow over it, which moves nothing out of the accessibility tree; with no theme loaded
the buttons read what they do.
The name carries the item, not just the direction. Move up repeated down a list is a rotor
full of identical buttons and no way to tell which is which. Set up-text, down-text and
moved-text to say it in your language β {label}, and {position} and {total} in the last of
them, are filled in, and anything else in the string is left as written, so a typo comes back
visible instead of blanking half the sentence.
aria-disabled, never disabled
The first item's up button and the last item's down button do nothing, and say so with
aria-disabled="true". They stay focusable and stay named.
A disabled button drops the focus that is on it to <body> β so a reader pressing up
repeatedly to walk an item to the top of the list arrives at the top and is nowhere, at the one
moment they most needed to be told where they are. The press is a no-op instead, and the live
region says nothing, because nothing happened.
Keyboard
| Key | What it does |
|---|---|
| Tab | Into the buttons, and on. They are ordinary <button>s, so Enter and Space are the browser's |
| Alt + β / β | Move the item in its own list, from anywhere inside it |
| Alt + Shift + β / β | On a board, take the item to the column beside it |
| Esc | Cancels a pointer drag in flight, putting the item back where it started |
Focus stays on the button that moved, which is what makes the second press the same press:
an item three places from where it belongs is three presses of one key, not three round trips
through the tab order. That is the rearrangeable listbox example's own reasoning, and
aria-keyshortcuts on the buttons is how a reader finds the shortcut exists.
Sideways asks for Shift as well, and that is not a preference. Alt and a sideways arrow is Back and Forward in Chrome, Firefox and Edge β a board advertising it on a button would be advertising a shortcut that leaves the page. Right to left, both the keys and the arrows on the buttons flip with the layout, so the arrow a reader presses is the column they see.
There is no grab mode β no Space to pick up, arrows to move, Space to drop.
It would be a second way to do what the buttons do, it needs role="application" on the grabbed
element to take the arrow keys off the screen reader's own navigation, and a mode is a state both
you and the reader have to hold. Alt and an arrow is the fast path without one.
Dragging
drag adds a grip to every item and lets a pointer move them. It is pointer events and a
captured pointer, not the
HTML drag and drop API:
that one has no touch support at all β which is why every library built on it ships a fallback
mode β and its ARIA half, aria-grabbed and aria-dropeffect, was deprecated in ARIA 1.1 with
nothing put in its place.
touch-action: noneis on the handle alone. On the item it would take the page's scrolling away everywhere a finger lands on a list, which is most of a phone screen.- The item is taken over as soon as the pointer passes the middle of its neighbour, which is what makes a drag feel the same in both directions between rows of different heights. The neighbour is measured with the dragged item lifted out of the way, so undoing a swap costs the dragged item's own height in travel instead of the two rows trading places under a still finger.
- The item moves in the DOM as it goes, rather than a ghost being animated into place at the end β so what you are dragging is the real item in its real position. On a board that includes the column: crossing one moves the item into that list, and its buttons are re-named for where it now is before the pointer is let go.
- One event per landing, not one per row crossed. A page persisting the order to a server should not spend six requests on one drag.
- Esc puts the item back where the drag started, on a board including the column it started in. A gesture the platform takes away β a scroll it decided was one, a call arriving β is put back the same way, because a gesture that never finished is not a move.
- The handle is
aria-hiddenand not focusable. The keyboard's way in is the two buttons beside it, and a focusable control that does nothing when you press it is worse than none.
Where the browser has it, the move is
moveBefore() rather than
insertBefore() β it moves the node instead of removing and re-inserting it, so focus, :active,
a running animation and any iframe or media inside the item survive the move. It is not Baseline
yet (Chrome 133, and an Interop 2026 focus area), so insertBefore() is the fallback, and the
focus this element knows about is put back by hand.
What the reader hears
One role="status" region, polite, clipped out of sight, saying where the item landed:
Bananas moved to position 2 of 4
and, when the item crossed a column, which column it is now in:
Bananas moved to Doing, position 1 of 3
Polite rather than assertive: the reader pressed the button, so the answer waits for a gap in what is already being read instead of cutting into it. The announcement is held back by 100ms β Primer's tested value for the same problem β because Alt and an arrow repeats when held and a drag crosses several rows a second, and without it the reader hears the journey instead of the destination.
The position is in the announcement and not in the button's name. Focus stays on the button after a move, and a name that changed under it would be read a second time by some screen readers β once as the button, once as the news.
Events
| Event | When | detail |
|---|---|---|
rearrange-move |
an item landed somewhere new | item β the <li>, from and to β zero-based positions in their own list, fromContainer and toContainer β the lists themselves, sameContainer β false when the item crossed a column |
Bubbles. Not fired for a press at the end of the travel, or for a drag that ended where it started, or for one cancelled with Esc.
sameContainer is the one to branch on. On a board from and to are positions in their
own columns, so a card that leaves position 1 of To do for position 1 of Done reports from: 0, to: 0 β a move that reads as nothing happening until you look at the containers. The flag is
derived from exactly those two nodes, so it cannot disagree with them:
element.addEventListener('rearrange-move', ({ detail }) => {
if (detail.sameContainer) return reorder(detail.item, detail.to);
move(detail.item, detail.toContainer, detail.to);
});
move rather than rearrange, because what happened is that one item moved β the list is
the thing that is rearrangeable, and the event is about the item.
Nothing is persisted β where the order is kept is your business, and this is where you hear about it:
element.addEventListener('rearrange-move', (event) => {
const order = [...event.currentTarget.items].map((item) => item.dataset.id);
fetch('/order', { method: 'POST', body: JSON.stringify(order) });
});
Attributes
| Attribute | Type | Default | What it does |
|---|---|---|---|
drag |
boolean | absent | Also let a pointer drag the items, by a grip the element adds. The buttons stay either way |
up-text |
string | Move {label} up |
The first button's name |
down-text |
string | Move {label} down |
The second button's name |
moved-text |
string | {label} moved to position {position} of {total} |
What the live region says after a move inside one list |
to-text |
string | Move {label} to {container} |
A board's crossing buttons. {container} is the column the item would land in |
moved-to-text |
string | {label} moved to {container}, position {position} of {total} |
What the live region says after a crossing |
On the items, not on the element:
| Attribute | On | What it does |
|---|---|---|
data-label |
an <li> or <tr> |
What that item is called in the buttons and the announcement |
data-rearrange-handle |
anything inside an item | Your own grip, used instead of the element's |
data-rearrange-cell |
a <td> or <th> in a row |
Which cell the controls go in. The last cell without one |
Table rows
A <table> works the same way, with two differences the markup forces:
<rearrange-elemental drag>
<table>
<caption>Peaks, in the order you would climb them</caption>
<thead>
<tr><th>Peak</th><th>Height</th><th><span class="sr">Order</span></th></tr>
</thead>
<tbody>
<tr><th scope="row"><span class="rearrange-elemental-handle" data-rearrange-handle aria-hidden="true"></span>MidΕΎor</th><td>2169</td><td data-rearrange-cell></td></tr>
<tr><th scope="row"><span class="rearrange-elemental-handle" data-rearrange-handle aria-hidden="true"></span>Δeravica</th><td>2656</td><td data-rearrange-cell></td></tr>
<tr><th scope="row"><span class="rearrange-elemental-handle" data-rearrange-handle aria-hidden="true"></span>Rtanj</th><td>1560</td><td data-rearrange-cell></td></tr>
</tbody>
</table>
</rearrange-elemental>
/* the arrows, the grip and the lift are the theme's; every line here is the page's */
table { border-collapse: separate; border-spacing: 0; inline-size: 100%; }
caption { margin-block-end: 0.5rem; font-weight: 600; text-align: start; }
th, td { padding: 0.4rem 0.6rem; text-align: start; border-block-end: 1px solid color-mix(in srgb, CanvasText 20%, transparent); }
tbody th { font-weight: 400; }
/* the grip sits in the row's name cell, on the text's own line */
.rearrange-elemental-handle { margin-inline-end: 0.35em; vertical-align: middle; }
/* the column the controls live in, kept as narrow as they are */
td[data-rearrange-cell] { inline-size: 1%; white-space: nowrap; }
/* the header cell over it has a name for screen readers and nothing to show */
.sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }- The controls go inside a cell, not between two of them. A
<span>as a child of<tr>is fostered out of the table by the parser, and one appended through the DOM instead lands in an anonymous cell that widens the row by a column nothing declared. Withoutdata-rearrange-cellthe last cell is used, so a table that has a spare column needs no markup change at all. - A row is named by one cell, as above.
- A grip at the front of a row is markup you write. A row's layout is its cells, so there is
no reordering the one the element would write out of the cell the buttons are in β
data-rearrange-handleon a<span>in the cell you want it in, and the element writes none of its own.
This is not <sortable-table-elemental> and the two do not belong on the
same table. That one sorts by a key in the cells and the order is derived; this one is a hand
order that nothing derives. A table wearing both has two answers to "what order are these rows
in", and the last thing pressed wins β which is a table whose order silently depends on what the
reader did last.
Boards
Several named lists in one element is a board. There is no attribute for it: two lists side by side have already said so, and a switch saying it again is a second place for the answer to live. Every item grows a button for the column on each side of it, named by where it would land.
<rearrange-elemental drag>
<h3 id="board-todo">To do</h3>
<ul aria-labelledby="board-todo">
<li><span class="rearrange-elemental-handle" data-rearrange-handle aria-hidden="true"></span>Measure the leaves</li>
<li><span class="rearrange-elemental-handle" data-rearrange-handle aria-hidden="true"></span>Warm the pot</li>
</ul>
<h3 id="board-doing">Doing</h3>
<ul aria-labelledby="board-doing">
<li><span class="rearrange-elemental-handle" data-rearrange-handle aria-hidden="true"></span>Boil the kettle</li>
</ul>
<h3 id="board-done">Done</h3>
<ul aria-labelledby="board-done"></ul>
</rearrange-elemental>
/* the element is display: contents, so whatever is around it lays the columns out β a board is
the one place worth overriding that, and this is the whole of it */
rearrange-elemental { display: grid; grid-template-rows: auto 1fr; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 0.4rem 0.75rem; }
h3 { margin: 0; font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
ul { margin: 0; padding: 0.35rem; min-block-size: 3.5rem; list-style: none; background: color-mix(in srgb, CanvasText 5%, transparent); border-radius: 0.4rem; }
li { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; background: Canvas; border: 1px solid color-mix(in srgb, CanvasText 15%, transparent); border-radius: 0.35rem; }
li + li { margin-block-start: 0.35rem; }
[data-rearrange-controls] { margin-inline-start: auto; }Press the arrows, or hold Alt and Shift and use β β from anywhere in the card. The end columns have three buttons rather than four, because there is no fourth column to name. The grip drags a card anywhere on the board, the empty column included, and Esc mid-drag puts it back in the column it was picked up from.
- Every list needs a name of its own:
aria-labelledbypointing at the heading above it, oraria-label. This is the load-bearing part β the buttons are named by their destination, so a list with no name leaves nothing to write in them. A board with one unnamed column writes no crossing buttons at all and throws an error saying which attribute is missing: half a row of named destinations and half of "Move Bananas to " reads as working, and what is left without them is still every column tidying itself, which is a working page. - Never a direction. "Move right" is what every board on the web says and what no reader off the screen can use. The column's own words are the only thing that says where the item is going, which is also why the announcement names the column it landed in.
- The columns are in the order they are written, and the buttons at the two ends of the board are simply absent β unlike up and down at the ends of a list, there is no destination to name, and a button whose name cannot be written is not a button. Pressing the last one you have takes the focus to the button that undoes it, which is the one control that is certainly there.
- An item keeps the position it held, clamped to what the destination already holds β so a press does one thing rather than two, and a card sent to an empty column arrives at the top of it rather than at the bottom of nowhere.
- A drag crosses columns too, and it is the buttons that make that safe to add rather than
the other way round:
WCAG 2.2 SC 2.5.7 is
satisfied before the first drag event, so the gesture is an extra and never the path. The column
under the pointer wins; the gutter between two columns, and the space around the board, keep
the column the drag is already in rather than dropping the card home from a gap two pixels
wide. An empty column is a target like any other, because what is hit-tested is the list's own
box β give it a
min-block-size, or there is nothing there to aim at. - No auto-scroll, and
overflowis yours. A board wider than its viewport has to be scrolled by hand mid-drag; both keyboard paths reach an off-screen column without one. A column withoverflowother thanvisibleclips a card dragged out of it β that is your CSS, not the element's.
Why buttons rather than a sideways drag with a keyboard mode behind it. Atlassian shipped the arrow-key version first β space to lift, arrows to cross β and their current guidance is the opposite: "Not all people can successfully complete pointer based drag operations. Provide other ways for people using assistive technologies to achieve the same outcomes", spelled as an action menu of movement outcomes on every card. Buttons are that answer with one press instead of two. Primer's drag-and-drop pattern does not cover this at all β it says outright that it "focuses on one-dimensional sorting for re-ordering items in a list".
Items that arrive later
The controls are written at upgrade. A list that grows afterwards β a row appended by a render, a
page of results fetched in, a column added to a board β gets its buttons from .update():
element.querySelector('ol').append(row);
element.update();
There is no MutationObserver behind it. One is the upgrade if this turns out to be the common
case rather than the rare one.
Without script
Your list, your table or your columns, in the order they arrived, and no buttons β which is a
list, and a working page. The element is display: contents, so wrapping markup you already had changes no layout at
all, upgraded or not.
Styling
The element's own stylesheet has no look in it. It places the controls, makes the handle
draggable, stops a drag selecting the text it passes over, and clips the live region β and it
deliberately says nothing about where in the item the controls sit. A display: flex on your
<li> would be this element laying out markup you wrote and may already have laid out, so the
controls are an inline box that sits on the text baseline, and pushing them to the far end is one
rule of yours:
li { display: flex; align-items: center; }
[data-rearrange-controls] { margin-inline-start: auto; }
That rule works with no !important and no longer selector because the theme's own margin on the
controls is written inside :where() and weighs nothing. It is a default, not a decision.
The grip is placed the same way, by being somewhere else in the markup: data-rearrange-handle
on a <span> at the front of the item leaves the element writing only the two buttons, which stay
in the box the rule above pushes to the end.
The controls, only when they are wanted
A grip and four buttons on every card is a lot of furniture for a reader using a pointer. One declaration fades them out until the pointer or the focus arrives:
rearrange-elemental { --rearrange-elemental-idle-opacity: 0; }
The default is 1, and deliberately: a control nobody can see is a capability nobody knows is
there, which is cognitive load added rather than removed. On a board it is worth paying, and what
comes with the property is the part that is easy to get wrong by hand β
:focus-withinbrings them back, so the keyboard reaches what the pointer reveals.- A coarse pointer always sees them. A finger has no hover and lands on nothing first.
- The fade is
opacity, neverdisplay: noneorvisibility: hidden. Those take the buttons out of the accessibility tree and out of the tab order β the whole keyboard path gone to save some space, and then:focus-withincan never fire, because nothing inside can take focus. - No transition under
prefers-reduced-motion: reduce.
| Custom property | Default | What it does |
|---|---|---|
--rearrange-elemental-control-size |
1.75em |
Theme. Both axes of one button, and of the grip |
--rearrange-elemental-gap |
0.15em |
Theme. Between the grip and the buttons, and between the buttons |
--rearrange-elemental-radius |
0.3rem |
Theme. Button corners |
--rearrange-elemental-color |
currentcolor |
Theme. The arrows |
--rearrange-elemental-hover |
currentcolor at 8% |
Theme. Fill under the pointer |
--rearrange-elemental-disabled-opacity |
0.3 |
Theme. The button at the end of its travel |
--rearrange-elemental-idle-opacity |
1 |
Theme. The controls when nothing points at the item or is focused inside it β 0 fades them in on hover and focus |
--rearrange-elemental-grip |
currentcolor at 45% |
Theme. The dots on the handle |
--rearrange-elemental-lift |
0 0.1rem 0.3rem currentcolor at 10% |
Theme. Under the item while it is dragged |
--rearrange-elemental-surface |
Canvas |
Theme. What the dragged item is painted on β re-point it on a card |
The arrows are drawn with borders rather than written as β², for the reason
<sortable-table-elemental>'s arrow is: text in a pseudo-element is read
out by some screen readers, and the button's name already says which way it goes.
1.75em is 28px at a 16px root β past the 24px
WCAG 2.2 SC 2.5.8 asks
for at AA, short of the 44px 2.5.5 asks for at AAA. Re-point the property on a page where these
are the primary controls.
What it will not do
- Auto-scroll under a drag. A board wider than its viewport is scrolled by hand; the keyboard paths need no scrolling at all, so this is the pointer's own convenience and not a way in.
- Grids or nesting. A column inside a column is a second board's worth of questions.
- Persistence. The event is the whole of the integration.
- A grab mode. Covered above: it is a second way to do what the buttons do, and it costs
role="application".