Candy Button
A glossy, puffed-up 3D call-to-action — drop shadow, blue edge ring, inset white stroke, and a top sheen. Lifts on hover, presses in on click.
hover to lift · press to sink · tab to focus
Usage
import { Plus } from "lucide-react";import { CandyButton } from "~/components/buttons/candy-button";// Recolor the whole button — gloss, ring, glow, focus — from one prop.<CandyButton icon={<Plus />} color="#296FF0" radius={18} onClick={addMember}>Add team member</CandyButton>
How the puff works
There's no 3D here — just four stacked shadows. A bottom 0 3px 6px drop shadow lifts the button off the page. A 0 0 0 1px ring in the same blue seats the edge so it doesn't look cut out. An inset 1px white stroke is the bright rim, and an inset white blur near the top is the sheen that sells the gloss.
On hover it rises two pixels and the drop shadow grows and tints blue, so it appears to float. On :active it drops a pixel, scales down a hair, and swaps to an inset dark shadow — the light pushes in and it reads as physically pressed.
Tailwind
<buttonclassName="inline-flex items-center justify-center gap-2 rounded-[18px]px-8 py-4 text-lg font-semibold text-whitebg-[linear-gradient(180deg,#3F7DF4,#296FF0)]shadow-[0_3px_6px_rgba(0,0,0,0.2),0_0_0_1px_#296FF0,inset_0_0_0_1px_rgba(255,255,255,0.4),inset_0_1px_6px_2px_rgba(255,255,255,0.24)]transition-[transform,box-shadow,filter] duration-150 ease-outhover:-translate-y-0.5 hover:brightness-105hover:shadow-[0_8px_18px_rgba(41,111,240,0.35),0_0_0_1px_#296FF0,inset_0_0_0_1px_rgba(255,255,255,0.45),inset_0_1px_6px_2px_rgba(255,255,255,0.3)]active:translate-y-px active:scale-[0.99] active:brightness-95focus-visible:outline-[3px] focus-visible:outline-offset-2 focus-visible:outline-[rgba(41,111,240,0.45)]"><Plus className="size-5" />Add team member</button>
Plain CSS (no Tailwind)
.btn-candy {--lift: 0px;display: inline-flex;align-items: center;gap: 0.6rem;border: none;color: #fff;border-radius: 18px;padding: 0.95rem 1.9rem;font: 600 1.125rem/1 system-ui, sans-serif;background: linear-gradient(180deg, #3f7df4, #296ff0);box-shadow:0 3px 6px rgba(0, 0, 0, 0.2), /* 1. drop — elevation */0 0 0 1px #296ff0, /* 3. blue ring */inset 0 0 0 1px rgba(255, 255, 255, 0.4), /* stroke: white 40% */inset 0 1px 6px 2px rgba(255, 255, 255, 0.24); /* 2. top sheen */transform: translateY(var(--lift));transition:transform 0.16s cubic-bezier(0.2, 0.7, 0.2, 1),box-shadow 0.16s ease,filter 0.16s ease;}.btn-candy:hover { --lift: -2px; filter: brightness(1.05); } /* rise */.btn-candy:active { transform: translateY(1px) scale(0.99); /* sink */box-shadow: 0 1px 3px rgba(0,0,0,.25), 0 0 0 1px #2563d8,inset 0 2px 6px rgba(0,0,0,.2); }.btn-candy:focus-visible { outline: 3px solid rgba(41,111,240,.45);outline-offset: 2px; }
Props
| prop | type | default | description |
|---|---|---|---|
| color | string | "#296FF0" | Base color — gloss, ring, glow & focus all derive from it. |
| radius | number | 18 | Corner radius in px. |
| icon | ReactNode | — | Optional leading icon (e.g. lucide <Plus />). |
| labelColor | string | "#fff" | Label / icon color. |
| children | ReactNode | — | Button label. |
| disabled | boolean | false | Dims and disables; cancels hover/press motion. |
| …props | ButtonHTMLAttributes | — | Any native button prop (onClick, type, aria-*). |
Source
download .zipThe full implementation, across 2 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
import { cn } from "~/lib/utils";export interface CandyButtonPropsextends React.ButtonHTMLAttributes<HTMLButtonElement> {/** Optional leading icon (e.g. a lucide <Plus />). */icon?: React.ReactNode;/** Base color; the gloss, ring, and every state derive from it. Default #296FF0. */color?: string;/** Corner radius in px. Default 18. */radius?: number;/** Label / icon color. Default #fff. */labelColor?: string;}/*** A glossy, 3D "candy" call-to-action. The puffed look is all box-shadow:* a bottom drop shadow for elevation, a 1px edge ring, a 1px inset white* stroke, and an inset white top sheen for the gloss. Everything themes off a* single `--candy-color`, so passing `color` recolors the whole button —* gradient, ring, hover glow, and focus outline included. Styling lives in* `.btn-candy` (app.css), so it works with or without Tailwind.*/export function CandyButton({icon,color,radius,labelColor,children,className,type = "button",style,...props}: CandyButtonProps) {const vars: React.CSSProperties = { ...style };if (color) (vars as Record<string, string>)["--candy-color"] = color;if (radius != null)(vars as Record<string, string>)["--candy-radius"] = `${radius}px`;if (labelColor) (vars as Record<string, string>)["--candy-label"] = labelColor;return (<button type={type} className={cn("btn-candy", className)} style={vars} {...props}>{icon ? <span className="btn-candy__icon">{icon}</span> : null}{children}</button>);}
/* ─── Candy CTA button (glossy 3D) ─────────────────────────────────── */.btn-candy {/* Everything derives from one base color, so the whole button is themeableby setting --candy-color (and optionally --candy-radius / --candy-label). */--candy-color: #296ff0;--candy-radius: 18px;--candy-label: #fff;--candy-top: color-mix(in srgb, var(--candy-color) 78%, #fff);--candy-press: color-mix(in srgb, var(--candy-color) 82%, #000);--lift: 0px;display: inline-flex;align-items: center;justify-content: center;gap: 0.6rem;cursor: pointer;border: none;color: var(--candy-label);border-radius: var(--candy-radius);padding: 0.95rem 1.9rem;font-family: var(--font-sans);font-size: 1.125rem;font-weight: 600;letter-spacing: -0.01em;background: linear-gradient(180deg, var(--candy-top), var(--candy-color));box-shadow:0 3px 6px rgba(0, 0, 0, 0.2), /* 1. drop shadow */0 0 0 1px var(--candy-color), /* 3. edge ring */inset 0 0 0 1px rgba(255, 255, 255, 0.4), /* stroke: white 40% */inset 0 1px 6px 2px rgba(255, 255, 255, 0.24); /* 2. inner sheen */transform: translateY(var(--lift));transition:transform 0.16s cubic-bezier(0.2, 0.7, 0.2, 1),box-shadow 0.16s ease,filter 0.16s ease;}.btn-candy:hover {--lift: -2px;filter: brightness(1.05);box-shadow:0 8px 18px color-mix(in srgb, var(--candy-color) 38%, transparent),0 0 0 1px var(--candy-color),inset 0 0 0 1px rgba(255, 255, 255, 0.45),inset 0 1px 6px 2px rgba(255, 255, 255, 0.3);}.btn-candy:active {--lift: 1px;filter: brightness(0.97);transform: translateY(1px) scale(0.99);box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),0 0 0 1px var(--candy-press),inset 0 2px 6px rgba(0, 0, 0, 0.2);}.btn-candy:focus-visible {outline: 3px solid color-mix(in srgb, var(--candy-color) 45%, transparent);outline-offset: 2px;}.btn-candy:disabled {cursor: not-allowed;opacity: 0.55;filter: none;--lift: 0px;}.btn-candy__icon {display: inline-flex;font-size: 1.25em;line-height: 0;}@media (prefers-reduced-motion: reduce) {.btn-candy {transition: none;}}