Skip to content
← components

Candy Button

new

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.

palette
icon

hover to lift · press to sink · tab to focus

Usage

example.tsx
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

CandyButton.tsx
<button
className="
inline-flex items-center justify-center gap-2 rounded-[18px]
px-8 py-4 text-lg font-semibold text-white
bg-[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-out
hover:-translate-y-0.5 hover:brightness-105
hover: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-95
focus-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)

candy-button.css
.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

proptypedefaultdescription
colorstring"#296FF0"Base color — gloss, ring, glow & focus all derive from it.
radiusnumber18Corner radius in px.
iconReactNodeOptional leading icon (e.g. lucide <Plus />).
labelColorstring"#fff"Label / icon color.
childrenReactNodeButton label.
disabledbooleanfalseDims and disables; cancels hover/press motion.
…propsButtonHTMLAttributesAny native button prop (onClick, type, aria-*).

The 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, …).

components/buttons/candy-button.tsx
import { cn } from "~/lib/utils";
export interface CandyButtonProps
extends 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>
);
}
app/styles/app.css
/* ─── Candy CTA button (glossy 3D) ─────────────────────────────────── */
.btn-candy {
/* Everything derives from one base color, so the whole button is themeable
by 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;
}
}