Ordinary buttons
There are different sizes.
<div class="flow">
<button>Primary</button>
<button disabled>Primary</button>
<button class="secondary">Secondary</button>
<button class="secondary" disabled>
<p>Secondary</p>
</button>
<button class="tertiary">Tertiary</button>
<button class="tertiary" disabled>
<p>Tertiary</p>
</button>
</div><div class="flow">
<button class="large">Primary</button>
<button class="secondary large">Secondary</button>
<button class="tertiary large">Tertiary</button>
</div><div class="flow">
<button class="small">Primary</button>
<button class="secondary small">Secondary</button>
<button class="tertiary small">Tertiary</button>
</div>You can change the background color.
<div class="flow" style="--ddi-c-button-background:rgba(50,150,100,1)">
<button>Primary</button>
<button disabled>Primary</button>
<button class="secondary">Secondary</button>
<button class="secondary" disabled>
<p>Secondary</p>
</button>
<button class="tertiary">Tertiary</button>
<button class="tertiary" disabled>
<p>Tertiary</p>
</button>
</div>States
A button that triggers something and then waits for an answer says so through
three attributes: aria-busy="true" while it waits, then data-ddi-result set
to success or failure, with any accompanying text in data-ddi-message.
A busy button pulsates, so a press that started something slow is visibly still
going rather than looking ignored. Under prefers-reduced-motion: reduce it
holds still at reduced strength instead.
When the answer lands the button marks itself — ✓ for a success, ✕ for a
failure — because success has no colour of its own in the palette, and a mark
survives both a reader who can’t tell the two colours apart and a project whose
error is something other than red. The mark trails the label: a leading icon
belongs to the label and says what the button does, while this one says what
already happened to it. A success is spent, and settles into the disabled
palette; a failure stays pressable, since pressing it again is the retry, and
takes the error colour with it through hover and active. The marks are
decorative to a screen reader — the state is in aria-disabled, and the words
are whatever the page does with data-ddi-message.
<div class="x-flow justify:space-around width:100cqw">
<div class="flow">
<button>Default</button>
<button aria-busy="true">Waiting</button>
<button data-ddi-result="success">Succeeded</button>
<button data-ddi-result="failure">Failed</button>
</div>
<div class="flow">
<button class="secondary">Default</button>
<button aria-busy="true" class="secondary">Waiting</button>
<button data-ddi-result="success" class="secondary">Succeeded</button>
<button data-ddi-result="failure" class="secondary">Failed</button>
</div>
<div class="flow">
<button class="tertiary">Default</button>
<button aria-busy="true" class="tertiary">Waiting</button>
<button data-ddi-result="success" class="tertiary">Succeeded</button>
<button data-ddi-result="failure" class="tertiary">Failed</button>
</div>
</div>processButton drives those attributes for you. Give it a button and an async
handler, and every click marks the button busy, waits, and then records the
answer the handler returns.
A success is terminal — the thing the button was for has happened, so it
refuses to run again and gets aria-disabled="true" to say so to a keyboard
reader as well as to the CSS. A failure is not: pressing a failed button again
simply retries it, clearing the last answer on the way. resetButton is the way
back from a success.
Nothing is rendered for you. Both ends of the run are reported as bubbling
events — ddi:process-started as the handler begins, and ddi:process-ended
with the outcome — and the page decides what to do with them. Here, the start
clears the last answer and the end writes the new one into a live region, so it
is announced as well as shown.
<div class="flow items:center">
<button id="process-save">Spara</button>
<button id="process-send" class="secondary">Skicka</button>
<button id="process-reset" class="tertiary small">Börja om</button>
</div>
<p id="process-message" role="status" aria-live="polite" class="mbs"></p>
<script>
import {
processButton,
resetButton,
type ProcessOutcome,
} from "@dynamic-type/ddi/client";
/** Stands in for the request a real handler would be waiting on. */
const answerAfter = (ms: number, outcome: ProcessOutcome) =>
new Promise<ProcessOutcome>((resolve) =>
setTimeout(() => resolve(outcome), ms),
);
processButton("process-save", () =>
answerAfter(2000, { ok: true, message: "Sparat!" }),
);
processButton("process-send", () =>
answerAfter(2000, {
ok: false,
message: "Katastrof! Servern ballade ur.",
}),
);
const say = (text: string) => {
const line = document.getElementById("process-message");
if (line) line.textContent = text;
};
// The button's own states are its business; showing the message is the
// page's. Both events bubble, so one listener each covers every button here
// — and a live region means a screen reader hears the answer too.
document.addEventListener("ddi:process-started", () => say(""));
document.addEventListener("ddi:process-ended", (event) =>
say(event.detail.message ?? ""),
);
// "Spara" succeeds, and a success is terminal — this is the way back.
document.getElementById("process-reset")?.addEventListener("click", () => {
resetButton("process-save");
resetButton("process-send");
say("");
});
</script>Countdown
Some presses shouldn’t take effect straight away — a delete you can still undo,
say. startCountdown shows the time left as the element’s own outline,
draining away clockwise, and tells you how it ended.
import { startCountdown } from "@dynamic-type/ddi/client";
const { finished, cancel } = startCountdown(undo, { duration: 3000 });
undo.addEventListener("click", cancel, { once: true });
if ((await finished) === "done") commit();
finished resolves once, to "done" when the time runs out or "cancelled"
when cancel() is called — or when another countdown is started on the same
element, which replaces this one. A cancel() after the end does nothing.
The countdown decides nothing. It doesn’t hide the element, doesn’t commit
anything and doesn’t stop when hovered; what happens at the end is up to the
page. That is the difference from a Bubble with behavior="timed-success-info",
which runs on this same countdown but closes itself.
Opt an element in with the data-ddi-countdown-outline attribute, which makes
it the outline’s positioning context. startCountdown adds the attribute if it
is missing, so any element works. The outline is an aria-hidden SVG the
countdown adds inside the element, so the element has to be one that can hold
children — a <button>, not an <input>.
The outline takes the place of the element’s border. It traces the border box, corner radii included, and lies just inside it, as wide as the border and in the element’s own text colour — which on a secondary button is the border’s colour too. While it runs the real border turns transparent, so what you see is the border itself running out: the time left at full strength, draining clockwise from the end of the top-left corner’s arc, over a faint track of the time already gone. It is redrawn whenever the element resizes.
A cancelled countdown freezes where it stopped and fades out as the border
comes back. Under prefers-reduced-motion: reduce it steps down once a second
instead of sliding.
The state is in data-ddi-countdown on the element — running, then done or
cancelled — for styling the element itself through the countdown.
| Property | Default | Purpose |
|---|---|---|
--ddi-countdown-color |
currentColor |
Colour of the time left. |
--ddi-countdown-track-color |
--ddi-countdown-color at 20 % |
Colour of the time gone. |
--ddi-countdown-end-color |
--ddi-countdown-color |
Colour the time left cools to as it runs out. |
--ddi-countdown-width |
the element’s border width | Thickness of the outline; 2px with no border. |
In the example, Ångra takes over Ta bort’s size so nothing around it moves. That
is the page’s doing, not the countdown’s: it copies the button’s size into
Ångra’s min-inline-size and min-block-size, which still lets a longer label
grow it.
The outline is only drawn, so a reader who can’t see it has to be told what is about to happen some other way. Here, a live region says it.
Anteckning: ”Köp mjölk”
<!-- A fixed size, so nothing moves as the buttons swap and the message below
them changes length. -->
<div style="inline-size: 24rem; max-inline-size: 100%">
<div class="x-flow items:center">
<p id="countdown-note">Anteckning: ”Köp mjölk”</p>
<button id="countdown-delete" class="secondary small">Ta bort</button>
<button
id="countdown-undo"
class="secondary small"
data-ddi-countdown-outline
style="display: none"
>
Ångra
</button>
<button
id="countdown-restore"
class="tertiary small"
style="display: none"
>
Börja om
</button>
</div>
<p
id="countdown-status"
role="status"
aria-live="polite"
class="mbs"
style="min-block-size: 1lh"
>
</p>
</div>
<script>
import { startCountdown } from "@dynamic-type/ddi/client";
const byId = (id: string) => document.getElementById(id) as HTMLElement;
const note = byId("countdown-note");
const remove = byId("countdown-delete");
const undo = byId("countdown-undo");
const restore = byId("countdown-restore");
// Not the `hidden` attribute: a button's own `display` outranks it.
const show = (element: HTMLElement, shown: boolean) => {
element.style.display = shown ? "" : "none";
};
const say = (text: string) => {
byId("countdown-status").textContent = text;
};
remove.addEventListener("click", async () => {
// Each button that takes this one's place does so at its size, so
// nothing around it moves — but may still grow to fit its own label.
const { width, height } = remove.getBoundingClientRect();
for (const button of [undo, restore]) {
button.style.minInlineSize = `${width}px`;
button.style.minBlockSize = `${height}px`;
}
show(remove, false);
show(undo, true);
// The outline is only drawn, so the deadline is also said out loud.
say("Anteckningen tas bort om 3 sekunder.");
// Shown before it starts: a hidden button has no box to trace.
const { finished, cancel } = startCountdown(undo, { duration: 3000 });
undo.addEventListener("click", cancel, { once: true });
// Nothing happens at the end unless the page makes it happen.
const outcome = await finished;
undo.removeEventListener("click", cancel);
show(undo, false);
if (outcome === "done") {
note.style.textDecoration = "line-through";
show(restore, true);
say("Anteckningen är borttagen.");
} else {
show(remove, true);
say("Ångrat.");
}
});
restore.addEventListener("click", () => {
note.style.textDecoration = "";
show(remove, true);
show(restore, false);
say("");
});
</script>ToggleButtons
---
import { ToggleButtons } from "@dynamic-type/ddi/server";
---
<ToggleButtons name="color" selectedvalue="green">
<ToggleButtons.Item value="blue">Blå</ToggleButtons.Item>
<ToggleButtons.Item value="green">Grön</ToggleButtons.Item>
<ToggleButtons.Item value="yellow">Gul</ToggleButtons.Item>
<ToggleButtons.Item value="red">Röd</ToggleButtons.Item>
<ToggleButtons.Item value="orange">Orange</ToggleButtons.Item>
</ToggleButtons>