A set of buttons that look slightly raised from the page. Each has a fill colour, a darker lip along its bottom edge made with an inset shadow, and a soft cast of the same colour beneath it. Hovering lifts the button a touch and pressing it flattens the lip, so the click feels physical. The set covers four intents (dark, blue, white and red), three sizes, a loading state with a spinner and a saved confirmation, and a disabled state. On phones the two columns stack and the rows keep their labels beside the buttons.
// Adapted from Cult UI (https://www.cult-ui.com/docs/components/neumorph-button)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Check, Download, Loader2, Trash2 } from "lucide-react";
/**
* Each intent is a fill, a darker lip along the bottom edge (the inset shadow)
* and a soft coloured cast underneath. Pressing shrinks the lip and the cast.
*/
const intents = {
ink: "bg-[#36322F] text-white enabled:hover:bg-[#4a4542] enabled:active:bg-[#2A2724] shadow-[inset_0_-2px_0_0_#171310,0_1px_6px_0_rgba(58,33,8,0.58)] enabled:hover:shadow-[inset_0_-2.5px_0_0_#171310,0_1.5px_8px_0_rgba(58,33,8,0.64)] enabled:active:shadow-[inset_0_-1px_0_0_#171310,0_0.5px_2px_0_rgba(58,33,8,0.7)] focus-visible:ring-[#36322F]",
blue: "bg-[#2265D8] text-white enabled:hover:bg-[#2B70E2] enabled:active:bg-[#1A58C4] shadow-[inset_0_-2px_0_0_#174FB0,0_1px_6px_0_rgba(28,100,242,0.58)] enabled:hover:shadow-[inset_0_-2.5px_0_0_#174FB0,0_1.5px_8px_0_rgba(28,100,242,0.64)] enabled:active:shadow-[inset_0_-1px_0_0_#1554AB,0_0.5px_2px_0_rgba(28,100,242,0.7)] focus-visible:ring-[#2265D8]",
paper: "border border-[#E0E0E0] bg-white text-[#36322F] enabled:hover:bg-[#F8F8F8] enabled:active:bg-[#F0F0F0] shadow-[inset_0_-2px_0_0_#E0E0E0,0_1px_6px_0_rgba(0,0,0,0.1)] enabled:hover:shadow-[inset_0_-2.5px_0_0_#E0E0E0,0_1.5px_8px_0_rgba(0,0,0,0.12)] enabled:active:shadow-[inset_0_-1px_0_0_#D8D8D8,0_0.5px_2px_0_rgba(0,0,0,0.15)] focus-visible:ring-[#36322F]",
red: "bg-[#D93A1E] text-white enabled:hover:bg-[#E6492D] enabled:active:bg-[#C2331A] shadow-[inset_0_-2px_0_0_#B22E17,0_1px_6px_0_rgba(214,58,31,0.58)] enabled:hover:shadow-[inset_0_-2.5px_0_0_#B22E17,0_1.5px_8px_0_rgba(214,58,31,0.64)] enabled:active:shadow-[inset_0_-1px_0_0_#B22E17,0_0.5px_2px_0_rgba(214,58,31,0.7)] focus-visible:ring-[#D93A1E]",
};
const sizes = {
small: "h-10 rounded-lg px-3 text-xs",
medium: "h-11 rounded-[9px] px-4 text-sm uppercase tracking-wide",
large: "h-14 rounded-[11px] px-6 text-lg",
};
type Intent = keyof typeof intents;
type Size = keyof typeof sizes;
function RaisedButton({ intent = "ink", size = "medium", loading = false, full = false, disabled, onClick, children }: { intent?: Intent; size?: Size; loading?: boolean; full?: boolean; disabled?: boolean; onClick?: () => void; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.button
type="button"
disabled={disabled || loading}
aria-busy={loading || undefined}
onClick={onClick}
whileHover={reduce || disabled || loading ? undefined : { scale: 1.02 }}
whileTap={reduce || disabled || loading ? undefined : { scale: 0.98, y: 1 }}
transition={{ type: "spring", stiffness: 400, damping: 14 }}
className={`inline-flex items-center justify-center gap-2 font-medium outline-none transition-[box-shadow,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[#F4F1EA] active:transition-none disabled:cursor-not-allowed disabled:opacity-60 ${intents[intent]} ${sizes[size]} ${full ? "w-full" : ""}`}
>
{loading && <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />}
{children}
</motion.button>
);
}
const rows: { intent: Intent; label: string; use: string }[] = [
{ intent: "ink", label: "Continue", use: "The main action on a light page" },
{ intent: "blue", label: "Upgrade plan", use: "A branded or promotional action" },
{ intent: "paper", label: "Not now", use: "The quiet second choice" },
{ intent: "red", label: "Delete board", use: "Something that cannot be undone" },
];
export default function RaisedButtonsWithPressedEdgeAndLoadingState() {
const [stage, setStage] = useState<"idle" | "saving" | "saved">("idle");
const timers = useRef<number[]>([]);
const save = () => {
timers.current.forEach(window.clearTimeout);
setStage("saving");
timers.current = [window.setTimeout(() => setStage("saved"), 1500), window.setTimeout(() => setStage("idle"), 3400)];
};
// Run the save once on load so the loading state is visible in a preview.
useEffect(() => {
const t = window.setTimeout(save, 500);
return () => {
window.clearTimeout(t);
timers.current.forEach(window.clearTimeout);
};
}, []);
return (
<section className="bg-[#F4F1EA] px-4 py-12 sm:px-6 sm:py-16">
<div className="mx-auto grid max-w-4xl gap-8 md:grid-cols-[1.1fr_1fr] md:items-start">
<div>
<h2 className="text-2xl font-semibold tracking-tight text-[#36322F]">Buttons with a lip you can press</h2>
<p className="mt-2 text-sm text-[#5f5852]">Each one sits a couple of pixels proud of the page. Press and the edge flattens.</p>
<ul className="mt-6 divide-y divide-[#e4dfd4] rounded-2xl bg-white/70 ring-1 ring-[#e4dfd4]">
{rows.map((r) => (
<li key={r.intent} className="flex items-center justify-between gap-4 px-4 py-3.5">
<p className="text-sm text-[#5f5852]">{r.use}</p>
<div className="shrink-0">
<RaisedButton intent={r.intent}>
{r.intent === "red" && <Trash2 className="h-4 w-4" aria-hidden="true" />}
{r.label}
</RaisedButton>
</div>
</li>
))}
</ul>
</div>
<div className="space-y-5">
<div className="rounded-2xl bg-white/70 p-5 ring-1 ring-[#e4dfd4]">
<h3 className="text-sm font-semibold text-[#36322F]">Three sizes</h3>
<div className="mt-4 flex flex-wrap items-end gap-3">
<RaisedButton size="small" intent="paper">
<Download className="h-3.5 w-3.5" aria-hidden="true" /> Export
</RaisedButton>
<RaisedButton size="medium" intent="blue">
Share
</RaisedButton>
<RaisedButton size="large">Publish</RaisedButton>
</div>
</div>
<div className="rounded-2xl bg-white/70 p-5 ring-1 ring-[#e4dfd4]">
<h3 className="text-sm font-semibold text-[#36322F]">Loading and done</h3>
<p className="mt-1 text-sm text-[#5f5852]">Press it to run the save again.</p>
<div className="mt-4">
<RaisedButton full loading={stage === "saving"} onClick={save}>
<AnimatePresence mode="wait" initial={false}>
<motion.span key={stage} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }} transition={{ duration: 0.15 }} className="inline-flex items-center gap-2">
{stage === "saved" && <Check className="h-4 w-4" aria-hidden="true" />}
{stage === "idle" ? "Save changes" : stage === "saving" ? "Saving" : "Saved"}
</motion.span>
</AnimatePresence>
</RaisedButton>
</div>
<p role="status" className="sr-only">
{stage === "saving" ? "Saving" : stage === "saved" ? "Changes saved" : ""}
</p>
<div className="mt-3">
<RaisedButton full intent="paper" disabled>
Disabled
</RaisedButton>
</div>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/raised-buttons-with-pressed-edge-and-loading-state.json
With two shadows on the button. An inset shadow offset upward by two pixels with no blur draws a solid darker strip along the bottom inside edge, and a normal soft shadow below adds the coloured cast. No extra elements are used.
The `enabled:` variant applies a style only when the button is not disabled, so a disabled or loading button does not react to hover or press.
Yes. Render an `<a>` with the same classes. Replace the `enabled:` prefixes with plain `hover:` and `active:` since links have no disabled state.
Only the small scale change does. The lip flattening is pure CSS through the `active:` shadow classes, so you can swap `motion.button` for `button` and keep most of the effect.
Copy one entry in `intents`, give it a new key, and set a fill, a lip colour about fifteen percent darker, and a cast in the same hue at around sixty percent opacity.
Adapted from Neumorph Button in Cult UI, used under its open-source licence. Licences and credits
Related: Button Set: Primary, Secondary, Ghost, Icon and Loading, Hold to Confirm Button with Progress Ring, Subscribe Button with Animated Confirmed State.
More animated buttons or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.