Web InnoventixFreeCode

3D Button Hover Effect

Original · free

A chunky 3D push-button that depresses on hover and press.

byWeb InnoventixReact + Tailwind
hover3dbutton
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/hover-3d-button.json
hover-3d-button.tsx
"use client";

import { useState, type ReactNode } from "react";

type Variant = "indigo" | "emerald" | "rose" | "amber" | "sky" | "slate";

const VARIANTS = {
  indigo: {
    edge: "bg-gradient-to-r from-indigo-900 via-indigo-800 to-indigo-900 dark:from-black dark:via-indigo-950 dark:to-black",
    front: "bg-indigo-500 text-white dark:bg-indigo-500",
    shadow: "bg-indigo-950/45 dark:bg-black/60",
    ring: "focus-visible:outline-indigo-400",
  },
  emerald: {
    edge: "bg-gradient-to-r from-emerald-900 via-emerald-800 to-emerald-900 dark:from-black dark:via-emerald-950 dark:to-black",
    front: "bg-emerald-500 text-emerald-950 dark:bg-emerald-500 dark:text-emerald-950",
    shadow: "bg-emerald-950/45 dark:bg-black/60",
    ring: "focus-visible:outline-emerald-400",
  },
  rose: {
    edge: "bg-gradient-to-r from-rose-900 via-rose-800 to-rose-900 dark:from-black dark:via-rose-950 dark:to-black",
    front: "bg-rose-500 text-white dark:bg-rose-500",
    shadow: "bg-rose-950/45 dark:bg-black/60",
    ring: "focus-visible:outline-rose-400",
  },
  amber: {
    edge: "bg-gradient-to-r from-amber-800 via-amber-700 to-amber-800 dark:from-black dark:via-amber-950 dark:to-black",
    front: "bg-amber-400 text-amber-950 dark:bg-amber-400 dark:text-amber-950",
    shadow: "bg-amber-900/45 dark:bg-black/60",
    ring: "focus-visible:outline-amber-300",
  },
  sky: {
    edge: "bg-gradient-to-r from-sky-900 via-sky-800 to-sky-900 dark:from-black dark:via-sky-950 dark:to-black",
    front: "bg-sky-500 text-white dark:bg-sky-500",
    shadow: "bg-sky-950/45 dark:bg-black/60",
    ring: "focus-visible:outline-sky-400",
  },
  slate: {
    edge: "bg-gradient-to-r from-slate-800 via-slate-700 to-slate-800 dark:from-black dark:via-slate-800 dark:to-black",
    front: "bg-slate-200 text-slate-900 dark:bg-slate-700 dark:text-slate-50",
    shadow: "bg-slate-900/40 dark:bg-black/60",
    ring: "focus-visible:outline-slate-400",
  },
} satisfies Record<
  Variant,
  { edge: string; front: string; shadow: string; ring: string }
>;

type Push3DButtonProps = {
  children: ReactNode;
  variant?: Variant;
  type?: "button" | "submit";
  onClick?: () => void;
  ariaLabel?: string;
  className?: string;
};

function Push3DButton({
  children,
  variant = "indigo",
  type = "button",
  onClick,
  ariaLabel,
  className = "",
}: Push3DButtonProps) {
  const v = VARIANTS[variant];
  return (
    <button
      type={type}
      onClick={onClick}
      aria-label={ariaLabel}
      className={[
        "group relative inline-flex cursor-pointer select-none appearance-none",
        "rounded-2xl border-0 bg-transparent p-0 outline-none",
        "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[6px]",
        v.ring,
        className,
      ].join(" ")}
    >
      {/* soft cast shadow that shrinks as the button is pushed down */}
      <span
        aria-hidden="true"
        className={[
          "absolute inset-0 rounded-2xl blur-[2px] will-change-transform",
          "translate-y-[3px] opacity-70 transition-transform duration-150 ease-out",
          "group-hover:translate-y-[1px] group-active:translate-y-0",
          "motion-reduce:transition-none",
          v.shadow,
        ].join(" ")}
      />
      {/* the fixed base / side wall — this is the depth you sink into */}
      <span
        aria-hidden="true"
        className={["absolute inset-0 rounded-2xl", v.edge].join(" ")}
      />
      {/* the top face — lifts 6px at rest, sinks to 1px on hover/press */}
      <span
        className={[
          "relative flex items-center justify-center gap-2 rounded-2xl",
          "px-7 py-3.5 text-sm font-semibold tracking-tight",
          "shadow-[inset_0_1px_0_rgba(255,255,255,0.35),inset_0_-1px_0_rgba(0,0,0,0.12)]",
          "-translate-y-[6px] transition-transform duration-150 ease-out will-change-transform",
          "group-hover:-translate-y-[3px] group-active:-translate-y-[1px]",
          "motion-reduce:transition-none motion-reduce:group-hover:-translate-y-[6px]",
          v.front,
        ].join(" ")}
      >
        {children}
      </span>
    </button>
  );
}

export default function Hover3DButton() {
  const [launched, setLaunched] = useState(false);
  const [cart, setCart] = useState(0);
  const [liked, setLiked] = useState(false);
  const [likes, setLikes] = useState(248);
  const [copied, setCopied] = useState(false);

  function handleCopy() {
    setCopied(true);
    if (typeof navigator !== "undefined" && navigator.clipboard) {
      void navigator.clipboard.writeText("npm i motion").catch(() => {});
    }
    window.setTimeout(() => setCopied(false), 1600);
  }

  function toggleLike() {
    setLiked((prev) => {
      setLikes((n) => (prev ? n - 1 : n + 1));
      return !prev;
    });
  }

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-28">
      <style>{`
        @keyframes h3d-nudge {
          0%, 100% { transform: translateX(0); }
          50% { transform: translateX(3px); }
        }
        .h3d-arrow { animation: h3d-nudge 1.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .h3d-arrow { animation: none; }
        }
      `}</style>

      {/* decorative grid backdrop */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 [background-image:linear-gradient(to_right,rgba(100,116,139,0.08)_1px,transparent_1px),linear-gradient(to_bottom,rgba(100,116,139,0.08)_1px,transparent_1px)] [background-size:44px_44px]"
      />

      <div className="relative mx-auto max-w-4xl">
        <div className="mx-auto max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Tactile controls
          </span>
          <h2 className="mt-5 text-balance text-3xl font-bold tracking-tight sm:text-4xl">
            Buttons that push back
          </h2>
          <p className="mx-auto mt-3 max-w-md text-pretty text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            A chunky 3D face that depresses into its base on hover and press —
            fully keyboard operable, with real actions wired to every click.
          </p>
        </div>

        {/* hero button */}
        <div className="mt-10 flex justify-center">
          <Push3DButton
            variant="indigo"
            onClick={() => setLaunched((p) => !p)}
            ariaLabel={launched ? "Reset onboarding" : "Get started for free"}
            className="text-base"
          >
            <span className="px-1 text-[0.95rem]">
              {launched ? "You're all set" : "Get started free"}
            </span>
            {launched ? (
              <svg
                viewBox="0 0 20 20"
                fill="none"
                aria-hidden="true"
                className="h-4 w-4"
              >
                <path
                  d="M4 10.5l3.5 3.5L16 6"
                  stroke="currentColor"
                  strokeWidth="2.2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            ) : (
              <svg
                viewBox="0 0 20 20"
                fill="none"
                aria-hidden="true"
                className="h3d-arrow h-4 w-4"
              >
                <path
                  d="M4 10h11M11 5.5L15.5 10 11 14.5"
                  stroke="currentColor"
                  strokeWidth="2.2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            )}
          </Push3DButton>
        </div>

        {/* action grid */}
        <div className="mt-14 grid gap-4 sm:grid-cols-3">
          {/* add to cart */}
          <div className="flex flex-col items-center gap-4 rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
            <Push3DButton
              variant="emerald"
              onClick={() => setCart((n) => n + 1)}
              ariaLabel="Add one item to cart"
            >
              <svg
                viewBox="0 0 20 20"
                fill="none"
                aria-hidden="true"
                className="h-4 w-4"
              >
                <path
                  d="M3 3h2l1.6 9.3a1 1 0 0 0 1 .7h6.8a1 1 0 0 0 1-.8L17 6H6"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
                <circle cx="8" cy="17" r="1.3" fill="currentColor" />
                <circle cx="15" cy="17" r="1.3" fill="currentColor" />
              </svg>
              Add to cart
            </Push3DButton>
            <p
              className="text-xs text-slate-500 dark:text-slate-400"
              aria-live="polite"
            >
              {cart === 0
                ? "Cart is empty"
                : `${cart} item${cart === 1 ? "" : "s"} in cart`}
            </p>
          </div>

          {/* like */}
          <div className="flex flex-col items-center gap-4 rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
            <Push3DButton
              variant="rose"
              onClick={toggleLike}
              ariaLabel={liked ? "Remove like" : "Like this"}
            >
              <svg
                viewBox="0 0 20 20"
                fill={liked ? "currentColor" : "none"}
                aria-hidden="true"
                className="h-4 w-4"
              >
                <path
                  d="M10 16.5S3.5 12.4 3.5 7.9A3.4 3.4 0 0 1 10 6.3a3.4 3.4 0 0 1 6.5 1.6c0 4.5-6.5 8.6-6.5 8.6Z"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinejoin="round"
                />
              </svg>
              {liked ? "Liked" : "Like"}
            </Push3DButton>
            <p
              className="text-xs text-slate-500 dark:text-slate-400"
              aria-live="polite"
            >
              {likes.toLocaleString()} people like this
            </p>
          </div>

          {/* copy */}
          <div className="flex flex-col items-center gap-4 rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
            <Push3DButton
              variant="slate"
              onClick={handleCopy}
              ariaLabel="Copy install command"
            >
              {copied ? (
                <svg
                  viewBox="0 0 20 20"
                  fill="none"
                  aria-hidden="true"
                  className="h-4 w-4"
                >
                  <path
                    d="M4 10.5l3.5 3.5L16 6"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
              ) : (
                <svg
                  viewBox="0 0 20 20"
                  fill="none"
                  aria-hidden="true"
                  className="h-4 w-4"
                >
                  <rect
                    x="7"
                    y="7"
                    width="9"
                    height="9"
                    rx="1.6"
                    stroke="currentColor"
                    strokeWidth="1.7"
                  />
                  <path
                    d="M4 13V5a1 1 0 0 1 1-1h8"
                    stroke="currentColor"
                    strokeWidth="1.7"
                    strokeLinecap="round"
                  />
                </svg>
              )}
              {copied ? "Copied!" : "npm i motion"}
            </Push3DButton>
            <p className="text-xs text-slate-500 dark:text-slate-400">
              Click to copy to clipboard
            </p>
          </div>
        </div>

        <p className="mt-10 text-center text-xs text-slate-400 dark:text-slate-500">
          Tip: focus a button with{" "}
          <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-mono text-[0.7rem] text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
            Tab
          </kbd>{" "}
          and press{" "}
          <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-mono text-[0.7rem] text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
            Space
          </kbd>{" "}
          to feel the push.
        </p>
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →