Actions
Button
Action button with variants and sizes
Preview
size
variant
children
loading
Code
import css from "./Button.module.css";
import { SpinnerIcon } from "@phosphor-icons/react/dist/csr/Spinner";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface ButtonProps {
size: Size | ResponsiveSize;
variant: "solid" | "soft" | "outline" | "ghost";
loading?: boolean;
type?: "button" | "submit" | "reset";
"aria-expanded"?: boolean;
"aria-controls"?: string;
children: React.ReactNode;
ref?: React.Ref<HTMLButtonElement>;
onClick?: (ev: React.MouseEvent<HTMLButtonElement>) => void;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Button({
size,
variant,
loading,
type,
"aria-expanded": ariaExpanded,
"aria-controls": ariaControls,
children,
ref,
onClick,
}: ButtonProps) {
// Enter and Space still click through pointer-events: none, and a submit would go twice.
const handleClick = (ev: React.MouseEvent<HTMLButtonElement>) => {
if (loading) {
ev.preventDefault();
return;
}
onClick?.(ev);
};
// aria-disabled, not disabled: the native one drops the focus mid-load.
return (
<button
ref={ref}
type={type}
className={`
${css.button}
${getSizeClasses(size)}
${css[variant]}
${loading ? css.loading : ""}
`}
aria-busy={loading || undefined}
aria-disabled={loading || undefined}
aria-expanded={ariaExpanded}
aria-controls={ariaControls}
onClick={handleClick}
>
{loading && (
<SpinnerIcon
className={css.button_spinner}
weight="bold"
aria-hidden="true"
/>
)}
<span className={css.button_content}>{children}</span>
</button>
);
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
border: var(--border-surface) solid;
border-radius: var(--radius-8);
font-weight: var(--weight-600);
gap: var(--space-8);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
background 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
/* The heights are the control ladder, shared with the form fields. */
&.xs {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
&.xs_base {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm_base {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md_base {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg_base {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm_mobile {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md_mobile {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg_mobile {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm_tablet {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md_tablet {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg_tablet {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm_desktop {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md_desktop {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg_desktop {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm_wide {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md_wide {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg_wide {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
}
.button_content {
display: inline-flex;
align-items: center;
gap: var(--space-8);
}
.button_spinner {
animation: spin 1s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&.solid {
background: var(--color-text);
color: var(--color-bg);
border-color: var(--color-text);
}
@media (hover: hover) {
&.solid:hover {
background: color-mix(in srgb, var(--color-text) 85%, var(--color-bg));
border-color: color-mix(in srgb, var(--color-text) 85%, var(--color-bg));
}
}
&.solid:active {
background: color-mix(in srgb, var(--color-text) 70%, var(--color-bg));
border-color: color-mix(in srgb, var(--color-text) 70%, var(--color-bg));
}
&.soft {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
border-color: transparent;
font-weight: var(--weight-500);
}
@media (hover: hover) {
&.soft:hover {
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
}
}
&.soft:active {
background: color-mix(in srgb, var(--color-accent) 28%, transparent);
}
&.outline {
background: none;
color: var(--color-text);
border-color: var(--color-text);
}
@media (hover: hover) {
&.outline:hover {
background: var(--color-text);
color: var(--color-bg);
}
}
&.outline:active {
background: color-mix(in srgb, var(--color-text) 85%, var(--color-bg));
border-color: color-mix(in srgb, var(--color-text) 85%, var(--color-bg));
color: var(--color-bg);
}
&.ghost {
background: none;
color: var(--color-text);
border-color: transparent;
}
@media (hover: hover) {
&.ghost:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
}
&.ghost:active {
background: color-mix(in srgb, var(--color-text) 14%, transparent);
}
&.loading {
pointer-events: none;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
<script setup lang="ts">
import { computed } from "vue";
import { PhSpinner } from "@phosphor-icons/vue";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface ButtonProps {
size: Size | ResponsiveSize;
variant: "solid" | "soft" | "outline" | "ghost";
loading?: boolean;
type?: "button" | "submit" | "reset";
ariaExpanded?: boolean;
ariaControls?: string;
onClick?: (ev: MouseEvent) => void;
}
const { size, loading, onClick } = defineProps<ButtonProps>();
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
// Enter and Space still click through pointer-events: none, and a submit would go twice.
const handleClick = (ev: MouseEvent) => {
if (loading) {
ev.preventDefault();
return;
}
onClick?.(ev);
};
</script>
<template>
<!-- aria-disabled, not disabled: the native one drops the focus mid-load. -->
<button
:type="type"
class="button"
:class="[sizeClasses, variant, { loading }]"
:aria-busy="loading || undefined"
:aria-disabled="loading || undefined"
:aria-expanded="ariaExpanded"
:aria-controls="ariaControls"
@click="handleClick"
>
<PhSpinner
v-if="loading"
class="button_spinner"
weight="bold"
aria-hidden="true"
/>
<span class="button_content"><slot /></span>
</button>
</template>
<style scoped>
.button {
display: inline-flex;
align-items: center;
justify-content: center;
border: var(--border-surface) solid;
border-radius: var(--radius-8);
font-weight: var(--weight-600);
gap: var(--space-8);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
background 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
/* The heights are the control ladder, shared with the form fields. */
&.xs {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
&.xs_base {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm_base {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md_base {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg_base {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm_mobile {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md_mobile {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg_mobile {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm_tablet {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md_tablet {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg_tablet {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm_desktop {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md_desktop {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg_desktop {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
height: var(--size-36);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.sm_wide {
height: var(--size-40);
padding: 0 var(--space-12);
font-size: var(--text-14);
}
&.md_wide {
height: var(--size-44);
padding: 0 var(--space-16);
font-size: var(--text-16);
}
&.lg_wide {
height: var(--size-48);
padding: 0 var(--space-20);
font-size: var(--text-16);
}
}
.button_content {
display: inline-flex;
align-items: center;
gap: var(--space-8);
}
.button_spinner {
animation: spin 1s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&.solid {
background: var(--color-text);
color: var(--color-bg);
border-color: var(--color-text);
}
@media (hover: hover) {
&.solid:hover {
background: color-mix(in srgb, var(--color-text) 85%, var(--color-bg));
border-color: color-mix(in srgb, var(--color-text) 85%, var(--color-bg));
}
}
&.solid:active {
background: color-mix(in srgb, var(--color-text) 70%, var(--color-bg));
border-color: color-mix(in srgb, var(--color-text) 70%, var(--color-bg));
}
&.soft {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
border-color: transparent;
font-weight: var(--weight-500);
}
@media (hover: hover) {
&.soft:hover {
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
}
}
&.soft:active {
background: color-mix(in srgb, var(--color-accent) 28%, transparent);
}
&.outline {
background: none;
color: var(--color-text);
border-color: var(--color-text);
}
@media (hover: hover) {
&.outline:hover {
background: var(--color-text);
color: var(--color-bg);
}
}
&.outline:active {
background: color-mix(in srgb, var(--color-text) 85%, var(--color-bg));
border-color: color-mix(in srgb, var(--color-text) 85%, var(--color-bg));
color: var(--color-bg);
}
&.ghost {
background: none;
color: var(--color-text);
border-color: transparent;
}
@media (hover: hover) {
&.ghost:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
}
&.ghost:active {
background: color-mix(in srgb, var(--color-text) 14%, transparent);
}
&.loading {
pointer-events: none;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Button size (height, padding and type). Fixed or responsive. |
| variant | "solid" | "soft" | "outline" | "ghost" | required | Heaviest to lightest. solid is filled, soft has a soft accent background, outline is a border that fills on hover, and ghost has no border or background and tints on hover. |
| loading | boolean | — | Waiting: a spinner before the content and the click blocked, without dropping focus. |
| type | "button" | "submit" | "reset" | — | Native button type. Without it the browser decides, and inside a form it acts as submit. |
| aria-expanded | boolean | — | Whether the panel it controls is open. Popover injects it when Button is its trigger. |
| aria-controls | string | — | Id of the panel the button opens and closes. Popover injects it when Button is its trigger. |
| children | React.ReactNode | required | Button content (text, or icon + text). |
| ref | React.Ref<HTMLButtonElement> | — | Ref to the native <button>; it lets Button act as a Popover trigger. |
| onClick | (ev: React.MouseEvent<HTMLButtonElement>) => void | — | Click callback; not called while loading. |