@propellerads/button
v11.0.1
Published
`Button` is the core component of the UI system. Version 10 is a rewrite: the look is built the shadcn way — one stylesheet, variants picked by [class-variance-authority](https://cva.style/), polymorphism from [Base UI](https://base-ui.com/react/component
Keywords
Readme
Button
Button is the core component of the UI system. Version 10 is a rewrite: the
look is built the shadcn way — one stylesheet, variants picked by
class-variance-authority, polymorphism from
Base UI — and every value in the
stylesheet comes from @propellerads/tokens.
Installation
bun add @propellerads/buttonThe button brings its own stylesheet: the built entry imports it, so importing the component is the whole of it. There is nothing else to remember, and no way to end up with a button that renders without its own CSS.
The tokens are the app's, not the button's — they carry the brand, and only the app knows which brand it is:
import '@propellerads/tokens/common.css';
import '@propellerads/tokens/propellerads.css'; // or monetag / propush / zeydooWithout them the button still looks like itself: every var() in the stylesheet
carries a fallback, and the font family falls through to the page's rather than
to the system sans. What the tokens change is the brand — the accent colour and
the typeface — and they change it for everything else on the page at the same
time.
The stylesheet is still exported as @propellerads/button/index.css, for a
consumer that extracts CSS by hand or wants it ahead of the JS.
Use
import Button, {ButtonTone, ButtonVariant} from '@propellerads/button';
<Button onClick={save}>Save</Button>
<Button variant="secondary">Cancel</Button>
<Button tone="danger" isLoading={isDeleting} onClick={remove}>Delete</Button>
<Button variant="secondary" tone="danger" startIcon={<Trash />}>Delete</Button>
<Button variant="ghost" icon={<Close />} aria-label="Close" />Form and tone are separate on purpose. A variant says where the colour goes —
into the fill, into the contour, into the label — and a tone says which colour
that is, so every form can be worn in every tone: a secondary Delete and a ghost
Approve are one prop apart, not a variant each. plain is the exception: it
paints nothing, and takes the tone only for its focus ring.
Three tones carry a meaning — action, danger, success. The fourth,
light, carries none: it is for a background the design system does not
control, a photograph or a promo panel or a dark header, where the other three
stop being legible. Reach for it because of what is behind the button, never for
emphasis.
<Button tone="light" variant="secondary">Learn more</Button>| Prop | Type | Default | What it does |
|---|---|---|---|
| variant | primary secondary ghost inline | primary | The form — how the box is drawn. link and plain still work, are deprecated, and both become inline |
| tone | action danger success light | action | The colour that form is worn in. light is for dark or busy backgrounds and means nothing by itself |
| startIcon, endIcon | ReactNode | — | An icon beside the label; the side it sits on tightens to 12px |
| icon | ReactNode | — | The one icon the button is. Makes it a 36px square, and requires an aria-label |
| isFullWidth | boolean | false | Stretches to the container |
| isLoading | boolean | false | Spinner, and clicks are swallowed |
| isAsync | boolean | false | Awaits a promise-returning onClick and shows the spinner while it runs |
| isDisabled | boolean | false | Switches the button off. The native disabled is not accepted — one name for one state |
| render | ReactElement \| (props, state) => ReactElement | — | Renders something other than a <button> |
| gtmAction, gtmLabel | string | — | Written out as gtm-action / gtm-label |
Everything a <button> accepts is passed through, type="submit" and id
included. The one exception is disabled: the button takes isDisabled and
nothing else, so that a flag arriving through a props spread cannot quietly
disagree with the one written at the call site.
isAsync
isAsync is the v9 behaviour under the same name: the click handler may return
a promise, and the button shows the spinner for as long as it runs.
<Button isAsync onClick={async () => { await save(); }}>Save</Button>A loading button is not disabled — it keeps its place in the tab order and
carries aria-busy, and the click is stopped in the handler instead. A disabled
button is disabled for real.
What is inside it
There is no size. Every button is 36px high, and the padding follows what it carries: 16px around a label, 12px on the side an icon sits on, none at all when the icon is the whole button.
That last case is a separate shape rather than a size, and the types say so —
icon cannot be combined with children, and it requires an aria-label,
because an icon on its own tells a screen reader nothing:
<Button icon={<Close />} aria-label="Close" />render
Base UI's polymorphism, in place of Radix's asChild. Pass
nativeButton={false} when what you render is not a <button>, so the keyboard
behaviour is supplied:
<Button nativeButton={false} render={<a href="/pricing">Pricing</a>} />buttonVariants
The class names are exported, for the cases where the look is wanted without the component — a menu item, a link in a paragraph:
import {buttonVariants} from '@propellerads/button';
<a className={buttonVariants({variant: 'secondary'})} href="/docs">Docs</a>Styling
The button reads tokens and nothing else — there is not a literal or a fallback
left in the file, so a page without @propellerads/tokens draws a button with no
colour of its own. On top of them it declares its own variables at the top of
.button, and those are the override API: no theme provider, any ancestor can
set them.
.checkout .button { --button-bg: var(--color-success); }| Variable | What it holds |
|---|---|
| --button-accent | The tone's colour. A form reads it; no form names a colour of its own |
| --button-accent-on | What goes on top of that colour when a form fills itself with it |
| --button-bg | The surface |
| --button-bg-hover | The surface under a pointer. Defaults to --button-bg: standing still is the default, and a form that answers says so itself |
| --button-fg | The label, and the spinner |
| --button-fg-hover | The label under a pointer — the answer of a form with no surface to change |
| --button-contour | The 1px inset border |
| --button-ring | The focus outline: the tone itself |
| --button-ring-width | How thick that outline is — 2px |
| --button-height | The height, 36px. The one literal left |
| --button-press | How long the press takes; see below |
| --inline-ring-start, --inline-ring-end | How far the inline ring stands off the label, per side |
A state belongs to the form, not to the colour. A form that owns its surface
deepens it: primary mixes --over-deepen into the tone and keeps its label,
which already sits on the fill. A form that owns none wears the same tone at
--opacity-tint — a film of the colour it is already showing, over whatever is
behind the button — so secondary and ghost darken on a panel and lighten on
nothing, and their label stays the tone. inline has no surface at all, so the
deepened tone goes into the label instead.
That is also why no colour in the token set carries a -hover: the same blue has
to go darker as a fill and lighter as a tint, and one token cannot say both.
light is the tone for a surface the system does not own, and it behaves like the
others: filled, it is the light end of the scale with a dark label; quiet, it is a
white contour and a white label that pick up a white film at --opacity-tint
under the pointer. That film is what makes it work on a photograph, where washing towards
the page colour would not.
The focus ring is drawn at all times and transparent until :focus-visible,
because an outline that appears out of nothing cannot be transitioned. Only its
colour changes, over --duration-200. It sits a pixel off the edge so the ring
and the contour do not touch. inline draws its ring on a pseudo-element
instead: it needs a different offset on the side its label ends, and
outline-offset takes one length for all four.
A disabled button fades to --opacity-disabled — the amount the whole library
uses — rather than to a number of its own, and the fade takes the contour and the
focus ring with it, because opacity does not pick and choose.
The press is the one asymmetric thing here. Going down takes --duration-100 —
it answers a finger that is already there, and a fifth of a second under it reads
as hesitation. Coming back up takes --duration-200: nobody is waiting on the
release, and the longer step reads as weight rather than as lag. Both run through
--button-press, which is the short step inside :active and the long one
outside it, because a transition is read from the style the element is moving to.
Every form is the same height and the same side padding, so the hit area and the
ring match everywhere — except inline, which has no box of its own by design.
An icon on its own goes in the icon slot, whatever the form: the button becomes
a 36px square with no side padding. Put it in children instead and the text
padding stays, so a 24px icon comes out 56px wide — padding doing a job that is
not text.
The legacy Button class
Every button also carries the bare class Button, which is what v9 put on it and
what applications style through — 28 selectors across the two products. It is
there so a version bump does not silently drop their spacing, and it goes away in
the next major: move those rules onto your own class.
Deprecated
Two names still work and are on the way out.
link and plain are not forms, they are holes in the type, and inline is
what both of them were reaching for: a button that owns no box, sits at the size
of the text around it and answers the pointer in its label.
A link that really navigates is the one exception. It is an <a> —
semantically, for the keyboard, and for the browser's own menu — so it is
render={<a />}, not variant="link" and not inline either. Everything that
merely looked like a link and acted on the page is inline.
plain was a hit area drawn around someone else's markup, and it existed
because v9 had nowhere to put an icon. inline carries an icon at the size of
the icon; ghost gives it a 36px square, which is the right call when it needs
a hit area of its own.
The same applies one level up: a component whose whole job is to be an icon
button — an arrow, a close, a chevron — is one line now, and the icon and its
aria-label are the only things worth keeping from it.
There are no sizes to deprecate: sm and lg are gone. In v9 the big button
changed its font with its height — 16px instead of 14px — and that only worked
while the font was a number in a file. Type is a role now, and there is no
400-weight 16px role to grow into, so a second height would differ in padding
alone, which is not a size. A denser interface is a decision about density, and
it arrives together with a type role that fits.
Migrating from v9
| v9 | v10 |
|---|---|
| type={ButtonType.Primary} | variant="primary" |
| type={ButtonType.Advanced} | variant="secondary" |
| type={ButtonType.Plain} | variant="inline" |
| htmlType="submit" | type="submit" |
| elementId | id |
| isDisabled | isDisabled — unchanged, and the native disabled is not accepted |
| isLoading | isLoading — unchanged |
| isFullWidth | isFullWidth — unchanged |
| isAsync | isAsync — unchanged |
| size={ButtonSize.Default} | drop it — every button is 36px |
| size={ButtonSize.Big} | drop it — there is no second height |
| <Button type={ButtonType.Plain}><Close /></Button> | <Button variant="inline" icon={<Close />} aria-label="Close" /> — a form, not the default primary, or a bare icon becomes a filled square |
| type={ButtonType.Secondary} | variant="secondary" |
| type={ButtonType.Danger} | tone="danger", on any form |
| type={ButtonType.Success} | tone="success", on any form |
| ButtonType | ButtonVariant |
| ThemeProvider from styled-components | --color-action and the other tokens |
styled-components is no longer a peer dependency, and the theme object it
carried is gone: a brand colour arrives as a CSS variable now. The new peers are
react and react-dom.
