Skip to content

START

JavaScript

The library requires none. Where a browser cannot do something declaratively there is an Alpine plugin to opt into. Fifteen directives; the nine here are the ones with no other home, and the rest sit on the part they belong to.

REQUIRES NO JAVASCRIPT IS NOT SHIPS NONE

Every component here draws, opens, closes and takes the keyboard with no script running at all. What the plugin adds is an improvement to something that already worked. A page that only half-works without its script has a dependency rather than an enhancement.

This book used to prove that by not loading any. It cannot any more. The switch in the rail is Theme toggle, the one control here whose job is to remember, and a reference that ships two themes has to be readable in both on every page. So Alpine is in the layout.

A check asserted it instead, driving the modal and the gallery with javaScriptEnabled: false. It was removed on 21 August 2026 with the rest of the interaction pass. The promise still holds, but it is a promise again rather than an assertion β€” turn JavaScript off and try them.

INSTALLING IT

A separate entry point, so the stylesheet never learns it exists. Alpine is an optional peer dependency: you bring your own.

import Alpine from 'alpinejs';
import higregDs from '@pipejesus/higreg-ds/alpine';

Alpine.plugin(higregDs);
Alpine.start();

x-ds-dismiss β€” a menu that closes behind a fragment link

Open it and pick a section. The panel closes. Every other disclosure in this book stays open when you do that, because they are the same component without this one attribute.

HTML
<div class="dropdown">
  <button
    class="dropdown-button nav-toggle nav-toggle-bars"
    popovertarget="ds-dismiss-menu"
    aria-label="Menu"
  ></button>

  <div class="dropdown-panel" id="ds-dismiss-menu" popover x-ds-dismiss>
    <ul class="menu">
      <li><a class="menu-item" href="#dismiss-one">First section</a></li>
      <li><a class="menu-item" href="#dismiss-two">Second section</a></li>
      <li><a class="menu-item" href="#dismiss-three">Third section</a></li>
    </ul>
  </div>
</div>

WHY THIS ONE CANNOT BE CSS

popovertargetaction="hide" is valid on <button> and <input> and on nothing else, so an <a> cannot ask a popover to close. For a link to another page that does not matter β€” the popover leaves with the document. For a same-document fragment link it matters a great deal: a URL differing only in its fragment is never a page load, so the browser scrolls to the section and leaves the menu sitting over the top of it.

It is one directive on the panel rather than a handler on every link, because a decision repeated on every anchor drifts β€” one gets added later without it and closes nothing, which is a bug nobody can see in a diff.

WHAT IT DELIBERATELY DOES NOT DO

A modified click is not a departure. Ctrl, Cmd, Shift and middle-click all open the destination somewhere else and leave the reader exactly where they were β€” with, they reasonably expect, the menu they were reading still open. Same for a link that targets another browsing context. And it waits a frame before hiding: closing the panel inside the click handler tears it out from under a navigation that has not happened yet, and the browser can lose the scroll target with it.

x-ds-form β€” a form that reports back in place

Press SEND with the fields blank: the browser's own message, in your browser's own language, written into this design system's hint rather than into a grey bubble. Fill them in and press again β€” the endpoint here does not exist, so you get the failure path, which is the half worth showing.

Whatever you would like to be called.

Where a reply should go.

HTML
<form class="flow" method="post" action="/api/enquiry" x-ds-form
      data-sent="Thank you β€” that reached its destination."
      data-failed="That did not send. Please try again, or write directly.">
  <div class="field">
    <label class="field-label" for="lv-name">NAME <span class="field-required">*</span></label>
    <input class="input" id="lv-name" name="name" type="text" required
           aria-describedby="lv-name-hint" placeholder="Ada Lovelace" />
    <p class="field-hint" id="lv-name-hint">Whatever you would like to be called.</p>
  </div>

  <div class="field">
    <label class="field-label" for="lv-mail">EMAIL <span class="field-required">*</span></label>
    <input class="input" id="lv-mail" name="email" type="email" required
           aria-describedby="lv-mail-hint" placeholder="[email protected]" />
    <p class="field-hint" id="lv-mail-hint">Where a reply should go.</p>
  </div>

  <input class="honeypot" type="text" name="company" tabindex="-1" aria-hidden="true" autocomplete="off" />

  <p class="notice notice-body" data-ds-status hidden></p>

  <button class="btn btn-primary" type="submit">SEND</button>
</form>

THE MESSAGES ARE THE BROWSER'S OWN WORDS

input.validationMessage is already written in the visitor's language, by their browser, for every constraint HTML can express. So the library ships no copy for this, needs no dictionary, and is correct in every locale a consumer has β€” including the ones they have not translated yet. Writing our own strings would have been worse in every language at once.

The field's resting hint is put back when the field becomes valid again. A hint says something useful β€” the format, the reason, what happens next β€” and losing it permanently after one mistake is a worse form than one that never validated.

novalidate IS SET BY THE DIRECTIVE, NOT BY YOU

It must not be in your HTML. A reader with no JavaScript would then lose native validation altogether and post an empty form. Setting it from the directive means the browser validates when nothing else can, and steps aside when something better can β€” which is the whole shape of progressive enhancement in one attribute.

THE REQUEST IS SHAPED LIKE THE BROWSER'S

Same method, same encoding, same fields. The only difference is an Accept: application/json header, which is how your endpoint knows to answer with JSON instead of a 303 redirect. One server code path serves both, so the path nobody tests cannot rot. Give the form a data-done and success navigates there instead of writing a message in place β€” a thank-you page, without the form's own page ever reloading.

x-ds-ripple β€” a ripple that starts where you pressed

Press near an edge. Without the directive the circle grows from the middle, because CSS cannot read a pointer; with it, from your finger.

HTML
<button type="button" class="btn ripple" x-ds-ripple>SECONDARY</button>
<button type="button" class="btn btn-primary ripple" x-ds-ripple>PRIMARY</button>
<button type="button" class="btn btn-quiet ripple" x-ds-ripple>QUIET</button>
<button type="button" class="btn btn-on-cream ripple" x-ds-ripple>ON CREAM</button>

Notes

IT MOVES WHERE, NEVER WHETHER

This is the smallest directive here and the one that changes the least. Ripple is CSS and works on its own β€” block the script and every press still ripples, from the centre. What the directive writes is the origin and, less obviously, the diameter: the distance from the press to the furthest corner, which is the one thing a stylesheet genuinely cannot know. A fixed multiplier is either too small for a corner press or wasteful for a centred one.

It listens for pointerdown rather than click β€” one event for mouse, touch and pen, and it fires before the press paints, so the coordinates belong to this press rather than the last one. A key press clears them instead, because the keyboard has no pointer and a ripple from wherever the mouse was left is worse than one from the middle.

x-ds-gallery β€” OPEN AT THE PICTURE YOU PRESSED

There is no specimen for this one, because the specimen is the whole of Gallery β€” press any square there and watch which picture arrives. Block the script and the gallery still opens, still steps with previous and next, still closes; it opens at the first picture instead of the one you pressed.

Three things a link cannot do, and that is the entire brief. command="show-modal" can open a dialog but cannot say where in it to start. Arrow keys cannot be asked for in markup. And a link changes the address bar β€” a walk through a gallery would otherwise leave an entry in the back button and a #shot-7 behind after it closed.

Which picture is current is asked of the geometry rather than counted, because the reader can also swipe or drag the scrollbar and a remembered index is wrong the moment they do. With one exception, and it was measured: two arrow presses in quick succession moved one picture, because the second read the geometry mid-scroll and found the slide it had just left still under the middle. So a step in flight is stepped from as an intention β€” and the geometry takes over again the instant a finger touches the track.

x-ds-after β€” A LINK DOES ITS JOB, AND A PANEL FOLLOWS

Put it on the row of links and name a <dialog>: x-ds-after="tin". Press one and the browser takes the file while the panel opens beside it. Nothing is gated β€” the link keeps its own href, nothing is fetched by script, and with no JavaScript at all the download still works and no panel appears.

It exists for one case: command="show-modal" is valid on a button and on nothing else, and a download has to be an anchor. It works because a download is not a navigation β€” a response the browser will not render is saved without the page moving, so the reader is still standing in front of the panel. Point it at a link to an ordinary page and the page leaves, taking the panel with it. See modal.

x-ds-motion β€” ONE PAUSE FOR A WALL OF CLIPS

Put it on the thing that holds several clips, put one clip-toggle-inline with data-ds-motion-toggle anywhere inside, and the clips themselves go bare. That is the only legitimate way to have a wall of moving pictures with no buttons on them.

Or hang it off the outside instead. Most things that hold a wall β€” a mesh, a collage, a gallery β€” clip their overflow, so a control inside is the only place an inline one can go. Move the arrangement into a clip-hang clip-hang-panel wrapper and the tab becomes its sibling: same directive, same one pause, and the button sits under the corner of the box rather than on top of a picture. See clip.

So why not leave the buttons off? Because x-ds-clip has always tolerated a clip with no control β€” it plays on scroll and paints nothing β€” so a page that left them out was a WCAG 2.2.2 failure with no error anywhere to say so. 2.2.2 asks for a mechanism, not one per thing that moves. Nine clips with nine buttons stamped on them satisfies the letter of it and ruins the picture, which is exactly how a wall ends up shipping with none.

It works by flipping data-ds-motion on the group, which every clip underneath reads before starting itself. Pausing the videos from outside would not hold on its own β€” each clip's observer starts it again the next time it crosses the viewport edge, so the control appears to work until the reader scrolls.

It lived inside x-ds-gallery first, where a control per cell is not merely ugly but impossible: a thumbnail is the button that opens the lightbox, and HTML forbids a button inside a button. A plain wall of clips wanted the same thing, so it came out into one function both directives call. Only the question “which videos” differs.

x-ds-theme β€” THE ONE THAT IS NOT AN ENHANCEMENT

The specimen is the rail on the left, and Theme toggle is the page. It is the exception to everything above: what it does is remember, a preference that outlives the page is state, and CSS holds none. Block the script and there is nothing to degrade to β€” so the button ships holding data-ds-pending and this directive takes it off, rather than leaving a dead control in a header.

It writes one attribute and reads it back through a MutationObserver β€” the document is the state, the button is a mirror. That is what keeps two switches on one page in step, and what lets a storage event from another tab move both.

x-ds-reveal β€” THE ONE THAT KNOWS NO EFFECTS

It says two things and neither is an effect: this has been seen, and this holds N characters. Both are measurements β€” counting characters is the one thing CSS cannot do for itself β€” and every stylesheet that cares reads them and decides what they mean. Lamp is the first, so a second effect is a stylesheet and a book page with no JavaScript changed at all.

It hides nothing itself. A reveal that darkens its host in CSS and waits for a script to undo it is one broken bundle away from a blank page, and search engines are the last to be told. Every rule is keyed on the attribute this directive writes, so with no script not one of them matches and the text is text. The observer disconnects the moment it has seen: a reveal is a one-off, and one left watching would fire on every scroll for the life of the page.

x-ds-accordion β€” THE ONE THAT ONLY MOVES FOCUS

The smallest here, and the clearest example of the line this file draws. Accordion answers Enter and Space, reports its own expanded state and closes its neighbours when they share a name β€” all of it the platform's, none of it repeated in JavaScript. What the platform does not do is move between the headings: Tab walks into an open panel and through everything in it before reaching the next one, which is a long way round a column of questions.

So Up, Down, Home and End move between headings, and that is the whole directive. It opens nothing β€” moving focus is not choosing, and an accordion that opened each panel as focus passed over it would fire an exclusive group's close animation four times on the way down. Remove the directive and Tab still reaches every heading in order.

x-ds-platform β€” THE ONE THAT GUESSES, AND SAYS SO

It names the machine a reader is on, so a download page can mark the card that suits them and a button can say "Download for Linux" instead of "Download Flatty". Every badge it reveals ships hidden, and every label it rewrites ships with a sentence that is already true.

That is not politeness, it is the whole design. "YOUR SYSTEM" is a claim about the reader, and a build has no idea who is reading β€” rendered by the build it is on the wrong card for most people, which is worse than being on none. So detection failing is a real outcome that looks like nothing, and it looks exactly like the page with no script.

It sniffs a string, which is a guess, so it decorates and never decides: every platform's files stay on the page, in the same order, whatever it concludes.

πŸ›‘ A phone is answered with silence, and the test for one comes first. An iPhone's agent says "like Mac OS X" and an Android's says "Linux", so the desktop tests place both of them unless something stops them β€” measured, an iPhone was told "Download for macOS" and shown a YOUR SYSTEM badge over a tarball it cannot open. Being unrecognised is a state this handles well; being told to download something that cannot run is not.

x-ds-copy β€” THE ONE THAT RENDERS ITS OWN BUTTON

It puts a block's text on the clipboard, and it is the reason the terminal has a button at all. navigator.clipboard is absent on an insecure origin and gated everywhere else, so a button rendered by the build is a button that may never work: this one ships hidden and is unhidden only where it can do something.

It is never the only way to get the text. The block stays selectable, always, which is what everybody did before clipboards were scriptable β€” a copy control is a convenience over an affordance the browser already gives, not a replacement for it. A refused write says nothing rather than claiming success.

The button's CONTENT decides where the word goes. An empty button gets the word written into it, which is every caller from before the pinned square existed. A button with something in it β€” an icon β€” keeps the picture and gets the word as its aria-label instead. Writing the word unconditionally is what the first version did, and it would have deleted the icon on the first render, before anybody pressed anything.

The copied moment lands twice for the same reason. The word still swaps where there is a word, and data-ds-copied goes on the button either way, so a picture that cannot say COPIED has something the stylesheet can paint.

FOURTEEN, AND THE COUNT IS STILL THE POINT

A catalogue of directives nobody has asked for is the same mistake as a token nothing reads, and in a design system it is the more expensive one: the documentation is the product, so an unused feature is a lie a reader can watch moving. Each of these shipped because something real needed it β€” six fragment links behind a hamburger, a form that should not reload the page, a video that must cost nothing until it is watched, a press that should start under the finger, a thumbnail that should open the picture it shows, a reader who chose paper and would like it still to be paper tomorrow, a heading that should arrive rather than simply be there, and a column of questions that Tab walks the long way round. The next will ship the same way, or not at all.

The other five are documented where they are used rather than here, because each belongs to one part and reads better beside it: splitflap, table, grade, copy β€” and platform, above, which had no part to belong to.