Add v2 Button component
Implement the Button component from the Probo Radix UI Figma: a <button> with size 1-4, the classic/solid/soft/surface/outline/ghost variants across the sand/gold/red/green/amber/sky scales with highContrast, iconStart/iconEnd slots, and a loading state that shows only a spinner. Corner radius is bound to size per the design (radius is theme-global, not a per-instance prop). Includes a paired ButtonSkeleton. Styling uses tailwind-variants/lite. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
130
packages/ui/src/v2/Button/Button.stories.tsx
Normal file
130
packages/ui/src/v2/Button/Button.stories.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { ArrowRightIcon, BookmarkSimpleIcon } from "@phosphor-icons/react";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { ButtonSkeleton } from "./ButtonSkeleton";
|
||||||
|
|
||||||
|
const sizes = [1, 2, 3, 4] as const;
|
||||||
|
const variants = ["classic", "solid", "soft", "surface", "outline", "ghost"] as const;
|
||||||
|
const colors = ["neutral", "gold", "red", "green", "amber", "sky"] as const;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "v2/Button",
|
||||||
|
component: Button,
|
||||||
|
args: {
|
||||||
|
children: "Edit profile",
|
||||||
|
size: 2,
|
||||||
|
variant: "solid",
|
||||||
|
color: "gold",
|
||||||
|
highContrast: false,
|
||||||
|
loading: false,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Button>;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Button>;
|
||||||
|
|
||||||
|
export const Playground: Story = {};
|
||||||
|
|
||||||
|
export const Sizes: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{sizes.map(size => (
|
||||||
|
<Button key={size} size={size}>
|
||||||
|
Edit profile
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Variants: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{variants.map(variant => (
|
||||||
|
<Button key={variant} variant={variant}>
|
||||||
|
{variant}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Colors: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{variants.map(variant => (
|
||||||
|
<div key={variant} className="flex items-center gap-3">
|
||||||
|
{colors.map(color => (
|
||||||
|
<Button key={color} variant={variant} color={color}>
|
||||||
|
{color}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HighContrast: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{colors.map(color => (
|
||||||
|
<Button key={color} color={color} highContrast>
|
||||||
|
{color}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithIcons: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button iconStart={<BookmarkSimpleIcon />}>Bookmark</Button>
|
||||||
|
<Button variant="soft" color="neutral" iconEnd={<ArrowRightIcon />}>Next</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button loading>Saving</Button>
|
||||||
|
<Button variant="soft" color="neutral" loading>Saving</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button disabled>Solid</Button>
|
||||||
|
<Button variant="soft" disabled>Soft</Button>
|
||||||
|
<Button variant="outline" color="neutral" disabled>Outline</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Skeleton: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ButtonSkeleton size={2} />
|
||||||
|
<ButtonSkeleton size={3} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
58
packages/ui/src/v2/Button/Button.tsx
Normal file
58
packages/ui/src/v2/Button/Button.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { SpinnerGapIcon } from "@phosphor-icons/react";
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import type { VariantProps } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
import { button } from "./variants";
|
||||||
|
|
||||||
|
export type ButtonProps
|
||||||
|
= Omit<ComponentProps<"button">, "color">
|
||||||
|
& VariantProps<typeof button>
|
||||||
|
& {
|
||||||
|
iconStart?: ReactNode;
|
||||||
|
iconEnd?: ReactNode;
|
||||||
|
// Shows a spinner in place of the leading icon and disables the button.
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clickable action (Radix "Button"). Renders a <button>; a styled <a> (Anchor)
|
||||||
|
// or router link (Link) are separate components. See contrib/claude/ui.md.
|
||||||
|
export function Button(props: ButtonProps) {
|
||||||
|
const {
|
||||||
|
size, variant, color, highContrast, className,
|
||||||
|
iconStart, iconEnd, loading = false, disabled, type = "button", children, ...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
|
className={button({ size, variant, color, highContrast, className })}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? <SpinnerGapIcon className="animate-spin" aria-hidden />
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
{iconStart}
|
||||||
|
{children}
|
||||||
|
{iconEnd}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
packages/ui/src/v2/Button/ButtonSkeleton.tsx
Normal file
28
packages/ui/src/v2/Button/ButtonSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import type { VariantProps } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
import { buttonSkeleton } from "./variants";
|
||||||
|
|
||||||
|
export type ButtonSkeletonProps = Omit<ComponentProps<"span">, "children"> & VariantProps<typeof buttonSkeleton>;
|
||||||
|
|
||||||
|
// Loading placeholder paired with Button: a pulse block matching its size and
|
||||||
|
// radius.
|
||||||
|
export function ButtonSkeleton(props: ButtonSkeletonProps) {
|
||||||
|
const { size, className, ...rest } = props;
|
||||||
|
|
||||||
|
return <span aria-hidden className={buttonSkeleton({ size, className })} {...rest} />;
|
||||||
|
}
|
||||||
136
packages/ui/src/v2/Button/variants.ts
Normal file
136
packages/ui/src/v2/Button/variants.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { tv } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
// Button (Radix "Button"). A <button>; an Anchor / Link are separate
|
||||||
|
// components (see contrib/claude/ui.md). The variant × color surface treatment
|
||||||
|
// resolves in the compound variants below.
|
||||||
|
export const button = tv({
|
||||||
|
base: [
|
||||||
|
"inline-flex shrink-0 items-center justify-center border border-transparent font-medium whitespace-nowrap",
|
||||||
|
"cursor-pointer outline-none transition-colors select-none",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
|
||||||
|
"disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
// Corner radius is bound to size (per the Figma, radius is global/theme,
|
||||||
|
// not a per-instance prop).
|
||||||
|
size: {
|
||||||
|
1: "h-6 gap-1 rounded-2 px-2 text-1 [&_svg]:size-4",
|
||||||
|
2: "h-8 gap-2 rounded-2 px-3 text-2 [&_svg]:size-4",
|
||||||
|
3: "h-10 gap-2 rounded-3 px-4 text-3 [&_svg]:size-5",
|
||||||
|
4: "h-12 gap-2 rounded-3 px-5 text-4 [&_svg]:size-5",
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
classic: "",
|
||||||
|
solid: "",
|
||||||
|
soft: "",
|
||||||
|
surface: "",
|
||||||
|
outline: "",
|
||||||
|
ghost: "",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
neutral: "",
|
||||||
|
gold: "",
|
||||||
|
red: "",
|
||||||
|
green: "",
|
||||||
|
amber: "",
|
||||||
|
sky: "",
|
||||||
|
},
|
||||||
|
highContrast: {
|
||||||
|
true: "",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
// ── solid: filled step-9 background, white text (dark for light hues) ──
|
||||||
|
{ variant: ["solid", "classic"], color: "neutral", class: "bg-sand-9 text-white hover:bg-sand-10" },
|
||||||
|
{ variant: ["solid", "classic"], color: "gold", class: "bg-gold-9 text-white hover:bg-gold-10" },
|
||||||
|
{ variant: ["solid", "classic"], color: "red", class: "bg-red-9 text-white hover:bg-red-10" },
|
||||||
|
{ variant: ["solid", "classic"], color: "green", class: "bg-green-9 text-white hover:bg-green-10" },
|
||||||
|
{ variant: ["solid", "classic"], color: "amber", class: "bg-amber-9 text-amber-12 hover:bg-amber-10" },
|
||||||
|
{ variant: ["solid", "classic"], color: "sky", class: "bg-sky-9 text-sky-12 hover:bg-sky-10" },
|
||||||
|
// classic adds elevation over solid
|
||||||
|
{ variant: "classic", class: "shadow-2" },
|
||||||
|
// solid high-contrast: step-12 background, step-1 text
|
||||||
|
{ variant: ["solid", "classic"], color: "neutral", highContrast: true, class: "bg-sand-12 text-sand-1 hover:bg-sand-12" },
|
||||||
|
{ variant: ["solid", "classic"], color: "gold", highContrast: true, class: "bg-gold-12 text-gold-1 hover:bg-gold-12" },
|
||||||
|
{ variant: ["solid", "classic"], color: "red", highContrast: true, class: "bg-red-12 text-red-1 hover:bg-red-12" },
|
||||||
|
{ variant: ["solid", "classic"], color: "green", highContrast: true, class: "bg-green-12 text-green-1 hover:bg-green-12" },
|
||||||
|
{ variant: ["solid", "classic"], color: "amber", highContrast: true, class: "bg-amber-12 text-amber-1 hover:bg-amber-12" },
|
||||||
|
{ variant: ["solid", "classic"], color: "sky", highContrast: true, class: "bg-sky-12 text-sky-1 hover:bg-sky-12" },
|
||||||
|
|
||||||
|
// ── soft: tinted step-3 background, step-11 text ──
|
||||||
|
{ variant: "soft", color: "neutral", class: "bg-sand-3 text-sand-11 hover:bg-sand-4" },
|
||||||
|
{ variant: "soft", color: "gold", class: "bg-gold-3 text-gold-11 hover:bg-gold-4" },
|
||||||
|
{ variant: "soft", color: "red", class: "bg-red-3 text-red-11 hover:bg-red-4" },
|
||||||
|
{ variant: "soft", color: "green", class: "bg-green-3 text-green-11 hover:bg-green-4" },
|
||||||
|
{ variant: "soft", color: "amber", class: "bg-amber-3 text-amber-11 hover:bg-amber-4" },
|
||||||
|
{ variant: "soft", color: "sky", class: "bg-sky-3 text-sky-11 hover:bg-sky-4" },
|
||||||
|
|
||||||
|
// ── surface: subtle background + border ──
|
||||||
|
{ variant: "surface", color: "neutral", class: "bg-sand-2 text-sand-11 border-sand-6 hover:bg-sand-3" },
|
||||||
|
{ variant: "surface", color: "gold", class: "bg-gold-2 text-gold-11 border-gold-6 hover:bg-gold-3" },
|
||||||
|
{ variant: "surface", color: "red", class: "bg-red-2 text-red-11 border-red-6 hover:bg-red-3" },
|
||||||
|
{ variant: "surface", color: "green", class: "bg-green-2 text-green-11 border-green-6 hover:bg-green-3" },
|
||||||
|
{ variant: "surface", color: "amber", class: "bg-amber-2 text-amber-11 border-amber-6 hover:bg-amber-3" },
|
||||||
|
{ variant: "surface", color: "sky", class: "bg-sky-2 text-sky-11 border-sky-6 hover:bg-sky-3" },
|
||||||
|
|
||||||
|
// ── outline: transparent background, step-7 border ──
|
||||||
|
{ variant: "outline", color: "neutral", class: "text-sand-11 border-sand-7 hover:bg-sand-3" },
|
||||||
|
{ variant: "outline", color: "gold", class: "text-gold-11 border-gold-7 hover:bg-gold-3" },
|
||||||
|
{ variant: "outline", color: "red", class: "text-red-11 border-red-7 hover:bg-red-3" },
|
||||||
|
{ variant: "outline", color: "green", class: "text-green-11 border-green-7 hover:bg-green-3" },
|
||||||
|
{ variant: "outline", color: "amber", class: "text-amber-11 border-amber-7 hover:bg-amber-3" },
|
||||||
|
{ variant: "outline", color: "sky", class: "text-sky-11 border-sky-7 hover:bg-sky-3" },
|
||||||
|
|
||||||
|
// ── ghost: transparent until hover ──
|
||||||
|
{ variant: "ghost", color: "neutral", class: "text-sand-11 hover:bg-sand-3" },
|
||||||
|
{ variant: "ghost", color: "gold", class: "text-gold-11 hover:bg-gold-3" },
|
||||||
|
{ variant: "ghost", color: "red", class: "text-red-11 hover:bg-red-3" },
|
||||||
|
{ variant: "ghost", color: "green", class: "text-green-11 hover:bg-green-3" },
|
||||||
|
{ variant: "ghost", color: "amber", class: "text-amber-11 hover:bg-amber-3" },
|
||||||
|
{ variant: "ghost", color: "sky", class: "text-sky-11 hover:bg-sky-3" },
|
||||||
|
|
||||||
|
// ── high-contrast text bump for the tinted variants ──
|
||||||
|
{ variant: ["soft", "surface", "outline", "ghost"], color: "neutral", highContrast: true, class: "text-sand-12" },
|
||||||
|
{ variant: ["soft", "surface", "outline", "ghost"], color: "gold", highContrast: true, class: "text-gold-12" },
|
||||||
|
{ variant: ["soft", "surface", "outline", "ghost"], color: "red", highContrast: true, class: "text-red-12" },
|
||||||
|
{ variant: ["soft", "surface", "outline", "ghost"], color: "green", highContrast: true, class: "text-green-12" },
|
||||||
|
{ variant: ["soft", "surface", "outline", "ghost"], color: "amber", highContrast: true, class: "text-amber-12" },
|
||||||
|
{ variant: ["soft", "surface", "outline", "ghost"], color: "sky", highContrast: true, class: "text-sky-12" },
|
||||||
|
],
|
||||||
|
defaultVariants: {
|
||||||
|
size: 2,
|
||||||
|
variant: "solid",
|
||||||
|
color: "gold",
|
||||||
|
highContrast: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buttonSkeleton = tv({
|
||||||
|
base: "inline-block shrink-0 animate-pulse bg-sand-3 align-middle",
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
1: "h-6 w-20 rounded-2",
|
||||||
|
2: "h-8 w-24 rounded-2",
|
||||||
|
3: "h-10 w-28 rounded-3",
|
||||||
|
4: "h-12 w-32 rounded-3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user