npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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.

size

NPM | Github

Installation

bun add @propellerads/button

The 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 / zeydoo

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