A button web component, with a visual "loading" state.
npm i -S @substrate-system/buttonThe CSS reads these variables. They are shared with the other
@substrate-system form controls, so an app can set them once. Each
one has a fallback, so they are all optional.
:root {
--substrate-text: black; /* label and spinner color */
--substrate-border: currentColor; /* button border */
--substrate-focus: currentColor; /* focus and hover outline */
}This exposes ESM and common JS via package.json exports field.
import { SubstrateButton } from '@substrate-system/button'const { SubstrateButton } = require('@substrate-system/button')Import CSS with a bundler, like esbuild.
import '@substrate-system/button/css'Or minified:
import '@substrate-system/button/css/min'Or use CSS imports:
@import url("../node_modules/@substrate-system/button/dist/index.min.css");You can set a name for this custom element with the static method
define. To use the default name, substrate-button, just import and
call .define().
Caution
If you change the name of the web component, it will break the CSS.
To use the default, call .define():
import { SubstrateButton } from '@substrate-system/button'
import '@substrate-system/button/css'
// create a web component named `substrate-button`
SubstrateButton.define()Or override the tag property to change the tag name:
import { SubstrateButton } from '@substrate-system/button'
// set a custom name
SubstrateButton.tag = 'cool-button'
SubstrateButton.define()This package includes ESM, Common JS, and pre-bundled versions.
Just import like normal.
This is a web component that knows how to render itself.
import { SubstrateButton } from '@substrate-system/button'
SubstrateButton.define()The client version should be used in conjunction with server-side rendering. It does not know how to render itself.
import { SubstrateButton } from '@substrate-system/button/client'
SubstrateButton.define()Take some attributes, and return a string of HTML.
import { html } from '@substrate-system/button/html'
const htmlString = html({
classes: ['abc'],
disabled: false,
autofocus: false
})Both versions hydrate. If the host already contains a <button> when
it connects, the element keeps that node and only adds behavior
(disabled, spinning, and attribute forwarding). This is the way to
use it with a framework like Preact, because the framework then owns
every node and nothing is replaced under it.
Render the markup like this:
html`<substrate-button spinning=${saving}>
<button class="substrate-button btn" type="submit">
<span class="btn-content">Save</span>
</button>
</substrate-button>`The CSS styles any <button> inside the host. The label must be
inside a .btn-content element, because the spinning state hides
.btn-content to make room for the spinner. The substrate-button and
btn classes match what the element renders itself, and are
recommended for consistency.
If you let the full version render the button, it moves the host's
child nodes into a new <button>. Node identity is kept, so a
framework can still update the label text in place. But if the
framework later inserts a new sibling node, the node goes into the
host, outside the button. Render the inner button yourself if the
children will change shape.
This package exposes minified JS and CSS files. Copy them to a location that is accessible to your web server, then link to them in HTML.
cp ./node_modules/@substrate-system/button/dist/index.min.js ./public/substrate-button.min.js
cp ./node_modules/@substrate-system/button/dist/index.min.css ./public/substrate-button.css<head>
<link rel="stylesheet" href="./substrate-button.css">
</head>
<body>
<!-- ... -->
<script type="module" src="./substrate-button.min.js"></script>
</body>See the example in ./example.
<substrate-button id="labeled" aria-label="Save your changes">
Save
</substrate-button>The host forwards these attributes to its inner native <button> and keeps
them in sync when they change: type, name, value, form, formaction,
formenctype, formmethod, formnovalidate, formtarget,
popovertarget, popovertargetaction, title, and aria-live.
Set aria-live on the <substrate-button> host only when your app needs
label changes announced.
The host's class also follows onto the inner button, including a button
rendered by your app before the element hydrates. Classes you add to the host
are added to the inner button, and classes you remove are removed from it.
The classes the element owns, substrate-button, btn, and spinning, are
never added or removed by a host class change.
On a button rendered by your app, the app owns the button's class. If the
app changes that attribute, include any host classes it wants kept. If the
app replaces its button, the new button does not receive the host class or
label again.
The inner button uses native button semantics. It has no role or
aria-disabled, and native disabled is the source of truth.
ariaLabel names the inner button, and the host never carries an
aria-label. A host aria-label attribute moves onto the inner button,
and a later setAttribute('aria-label', ...) also moves. The element keeps
it off the host, because aria-label on an element with no role fails axe.
Reading el.ariaLabel returns the inner button's label. Removing the host
attribute later does nothing, because it is already gone. To clear the
label, set el.ariaLabel = null. In Preact or another framework, pass
ariaLabel as a property, not aria-label as an attribute. Removing the
prop then clears the label.
const el = document.querySelector('substrate-button')
el.ariaLabel = 'Close the dialog'
// An empty string or null removes the label
el.ariaLabel = ''aria-live is opt-in: set it on the <substrate-button> host when your app
needs label changes announced. While the button is spinning, its inner button
has aria-busy="true".
Upgrade note: if your app relied on the previous default aria-live value,
set aria-live on the host to restore it.
The button does not announce loading on its own. Render one role="status"
element in the app, keep it in the DOM, and update its text as the operation
starts and ends. It may use the app's visually hidden class.
<substrate-button id="save-button">Save</substrate-button>
<p id="save-status" role="status" class="visually-hidden"></p>const button = document.querySelector('#save-button')
const status = document.querySelector('#save-status')
async function saveWithAnnouncement () {
button.spinning = true
status.textContent = 'Saving'
await save()
button.spinning = false
status.textContent = 'Saved'
}Putting the message in a separate region makes the announcement reliable: the status element exists before its text changes, and the announcement does not depend on which element has focus.
Add an attribute spinning to set the loading state.
const el = document.querySelector('substrate-button')
el?.setAttribute('spinning', '')
// now it shows a spinning animationRemove the attribute to stop the animation:
const el = document.querySelector('substrate-button')
el?.removeAttribute('spinning')Or, if you have a reference to the element, you can set the spinning property
for the same effect:
const el = document.querySelector('substrate-button')
el.spinning = true // spin
el.spinning = false // stop