Button
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.
hover to brighten · press to sink · tab to focus
Usage
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.
// 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
| prop | type | default | description |
|---|---|---|---|
| variant | default | black | white | outline | ghost | destructive | success | link | "default" | Visual style. |
| size | "sm" | "default" | "lg" | "icon" | "default" | Height & padding. |
| asChild | boolean | false | Render as the child element (e.g. a Link), merging styles. |
| …props | ButtonHTMLAttributes | — | Any native button prop (onClick, disabled, type, …). |
Source
download .zipThe full implementation. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
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 ButtonPropsextends 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 };