Skip to content
← components

Button

new

A layered gradient button — fill, inner-light rim, and a color-matched shadow on stacked pseudo-elements. Brightens on hover, presses on click. Eight variants, four sizes.

variant
size
disabled

hover to brighten · press to sink · tab to focus

Usage

example.tsx
import { Button } from "~/components/ui/button";
import { Link } from "react-router";
<Button>Get started</Button>
<Button variant="outline" size="sm">Cancel</Button>
// asChild merges styles onto a Link (or any element)
<Button asChild>
<Link to="/components">Browse components</Link>
</Button>

How the depth works

It's a class-variance-authority button with the look built from two pseudo-elements. A before: layer paints the gradient fill, an after: layer adds the inner-light rim (a top highlight + 1px inset stroke), and the element itself carries a color-matched drop shadow. The label sits on a relative z-10 wrapper above both layers.

States are cheap: hover brightnesses the fill, active nudges it down a pixel and dims it. Because every variant shares this structure, a new color is just two gradient stops and a shadow tint — and asChild lets a <Link> wear the exact same styling.

button-variants.ts
// The depth is two pseudo layers + a colored shadow; content rides on z-10.
default: [
"text-white shadow-[0_1px_2px_rgba(0,0,0,.35),0_6px_18px_-6px_hsl(var(--primary)/.6)]",
// gradient fill
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-gradient-to-b " +
"before:from-[hsl(217_92%_64%)] before:to-[hsl(217_90%_50%)]",
// inner-light rim
"after:absolute after:inset-0 after:rounded-[inherit] after:pointer-events-none " +
"after:shadow-[inset_0_1px_0_rgba(255,255,255,.3),inset_0_0_0_1px_rgba(255,255,255,.08)]",
// states
"hover:before:brightness-110 active:translate-y-px active:before:brightness-90",
],

Props

proptypedefaultdescription
variantdefault | black | white | outline | ghost | destructive | success | link"default"Visual style.
size"sm" | "default" | "lg" | "icon""default"Height & padding.
asChildbooleanfalseRender as the child element (e.g. a Link), merging styles.
…propsButtonHTMLAttributesAny native button prop (onClick, disabled, type, …).

The full implementation. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).

components/ui/button.tsx
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils";
/**
* Layered button ported from B3OS: a gradient fill on a `before:` layer, an
* inner-light rim on an `after:` layer, and a color-matched drop shadow on the
* element. Hover brightens the fill, active presses it in. Content rides on a
* `relative z-10` wrapper above the pseudo layers.
*/
const buttonVariants = cva(
[
"relative inline-flex items-center justify-center gap-2 overflow-hidden whitespace-nowrap rounded-lg font-medium",
"transition-[filter,transform,box-shadow,background-color,color,border-color] duration-150 ease-out",
"disabled:pointer-events-none disabled:opacity-40",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--ring))] focus-visible:ring-offset-2 focus-visible:ring-offset-[hsl(var(--background))]",
"[&_svg]:size-4 [&_svg]:shrink-0",
],
{
variants: {
variant: {
default: [
"text-white shadow-[0_1px_2px_rgba(0,0,0,0.35),0_6px_18px_-6px_hsl(var(--primary)/0.6)]",
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-gradient-to-b before:from-[hsl(217_92%_64%)] before:to-[hsl(217_90%_50%)] before:transition-[filter] before:duration-150",
"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_1px_0_rgba(255,255,255,0.3),inset_0_0_0_1px_rgba(255,255,255,0.08)]",
"hover:before:brightness-110 active:translate-y-px active:before:brightness-90",
],
black: [
"text-white shadow-[0_1px_2px_rgba(0,0,0,0.5),0_6px_18px_-8px_rgba(0,0,0,0.7)]",
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-gradient-to-b before:from-[hsl(0_0%_26%)] before:to-[hsl(0_0%_10%)] before:transition-[filter] before:duration-150",
"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_1px_0_rgba(255,255,255,0.1),inset_0_0_0_1px_rgba(255,255,255,0.05)]",
"hover:before:brightness-125 active:translate-y-px active:before:brightness-95",
],
white: [
"text-[hsl(var(--foreground))] shadow-[0_1px_2px_rgba(0,0,0,0.4),0_6px_18px_-8px_rgba(0,0,0,0.5)]",
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-gradient-to-b before:from-[hsl(0_0%_20%)] before:to-[hsl(0_0%_12%)] before:transition-[filter] before:duration-150",
"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_1px_0_rgba(255,255,255,0.14),inset_0_0_0_1px_rgba(255,255,255,0.06)]",
"hover:before:brightness-125 active:translate-y-px active:before:brightness-95",
],
destructive: [
"text-white shadow-[0_1px_2px_rgba(0,0,0,0.35),0_6px_18px_-6px_hsl(0_84%_60%/0.55)]",
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-gradient-to-b before:from-[hsl(0_84%_62%)] before:to-[hsl(0_74%_48%)] before:transition-[filter] before:duration-150",
"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_1px_0_rgba(255,255,255,0.25),inset_0_0_0_1px_rgba(255,255,255,0.08)]",
"hover:before:brightness-105 active:translate-y-px active:before:brightness-90",
],
success: [
"text-white shadow-[0_1px_2px_rgba(0,0,0,0.35),0_6px_18px_-6px_hsl(150_70%_45%/0.55)]",
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-gradient-to-b before:from-[hsl(150_66%_48%)] before:to-[hsl(150_70%_36%)] before:transition-[filter] before:duration-150",
"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_1px_0_rgba(255,255,255,0.25),inset_0_0_0_1px_rgba(255,255,255,0.08)]",
"hover:before:brightness-105 active:translate-y-px active:before:brightness-90",
],
outline:
"border border-[hsl(var(--border))] bg-transparent text-[hsl(var(--foreground))] hover:border-[hsl(var(--primary))]/40 hover:bg-[hsl(var(--muted))] active:translate-y-px",
ghost:
"text-[hsl(var(--foreground))] hover:bg-[hsl(var(--muted))] active:bg-[hsl(var(--muted))]",
link: "text-[hsl(var(--primary))] underline-offset-4 hover:underline",
},
size: {
sm: "h-9 px-3.5 text-sm",
default: "h-10 px-5 text-sm",
lg: "h-11 px-6 text-base",
icon: "size-10 px-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
/** Content rides above the gradient/rim pseudo-layers. */
const CONTENT = "relative z-10 inline-flex items-center justify-center gap-2";
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, type, children, ...props }, ref) => {
const classes = cn(buttonVariants({ variant, size, className }));
if (asChild) {
const child = React.Children.only(children) as React.ReactElement<{
children?: React.ReactNode;
}>;
return (
<Slot className={classes} ref={ref} {...props}>
{React.cloneElement(
child,
{},
<span className={CONTENT}>{child.props.children}</span>
)}
</Slot>
);
}
return (
<button className={classes} ref={ref} type={type ?? "button"} {...props}>
<span className={CONTENT}>{children}</span>
</button>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };