Raised Buttons with Pressed Edge and Loading State

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.

Code

// 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

When to use it

How to customize it

Accessibility

Questions

How is the raised edge made?

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.

Why do the hover styles use `enabled:`?

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.

Can I use it as a link?

Yes. Render an `<a>` with the same classes. Replace the `enabled:` prefixes with plain `hover:` and `active:` since links have no disabled state.

Does the press animation need Framer Motion?

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.

How do I add another colour?

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.

More in Animated Buttons

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.

Product

Who It's For

Features

Resources

Company

View Sitemap