DDI

Buttons

This page was updated in v3.6.0

Ordinary buttons

There are different sizes.

Medium buttons
<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>
Large buttons
<div class="flow">
    <button class="large">Primary</button>
    <button class="secondary large">Secondary</button>
    <button class="tertiary large">Tertiary</button>
</div>
Small buttons
<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.

Custom background
<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.

State
<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.

Press these and watch the states

<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.

Ta bort, then Ångra within three seconds — or don't

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

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>