Web InnoventixFreeCode

Button Fill Hover Effect

Original · free

Buttons with a colour fill that sweeps across on hover.

byWeb InnoventixReact + Tailwind
hoverbuttonfill
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-button-fill.json
hover-button-fill.tsx
"use client";

import { useState } from "react";

type Variant = {
  id: string;
  label: string;
  hint: string;
  base: string;
  fill: string;
  fillText: string;
  span: string;
  ring: string;
};

const VARIANTS: Variant[] = [
  {
    id: "ltr",
    label: "Deploy to production",
    hint: "Sweep · left → right",
    base:
      "border-indigo-300 text-indigo-700 dark:border-indigo-500/50 dark:text-indigo-300",
    fill: "bg-indigo-600 dark:bg-indigo-500",
    fillText: "group-hover:text-white group-focus-visible:text-white",
    span: "origin-left scale-x-0 group-hover:scale-x-100 group-focus-visible:scale-x-100",
    ring: "focus-visible:ring-indigo-500/60",
  },
  {
    id: "rtl",
    label: "Roll back release",
    hint: "Sweep · right → left",
    base:
      "border-rose-300 text-rose-700 dark:border-rose-500/50 dark:text-rose-300",
    fill: "bg-rose-600 dark:bg-rose-500",
    fillText: "group-hover:text-white group-focus-visible:text-white",
    span: "origin-right scale-x-0 group-hover:scale-x-100 group-focus-visible:scale-x-100",
    ring: "focus-visible:ring-rose-500/60",
  },
  {
    id: "btt",
    label: "Publish article",
    hint: "Sweep · bottom → top",
    base:
      "border-emerald-300 text-emerald-700 dark:border-emerald-500/50 dark:text-emerald-300",
    fill: "bg-emerald-600 dark:bg-emerald-500",
    fillText: "group-hover:text-white group-focus-visible:text-white",
    span: "origin-bottom scale-y-0 group-hover:scale-y-100 group-focus-visible:scale-y-100",
    ring: "focus-visible:ring-emerald-500/60",
  },
  {
    id: "ttb",
    label: "Download report",
    hint: "Sweep · top → bottom",
    base:
      "border-sky-300 text-sky-700 dark:border-sky-500/50 dark:text-sky-300",
    fill: "bg-sky-600 dark:bg-sky-500",
    fillText: "group-hover:text-white group-focus-visible:text-white",
    span: "origin-top scale-y-0 group-hover:scale-y-100 group-focus-visible:scale-y-100",
    ring: "focus-visible:ring-sky-500/60",
  },
  {
    id: "center",
    label: "Start free trial",
    hint: "Sweep · from centre",
    base:
      "border-violet-300 text-violet-700 dark:border-violet-500/50 dark:text-violet-300",
    fill: "bg-violet-600 dark:bg-violet-500",
    fillText: "group-hover:text-white group-focus-visible:text-white",
    span: "origin-center scale-x-0 group-hover:scale-x-100 group-focus-visible:scale-x-100",
    ring: "focus-visible:ring-violet-500/60",
  },
  {
    id: "amber",
    label: "Upgrade plan",
    hint: "Sweep · left → right",
    base:
      "border-amber-300 text-amber-700 dark:border-amber-500/50 dark:text-amber-300",
    fill: "bg-amber-500 dark:bg-amber-400",
    fillText:
      "group-hover:text-amber-950 group-focus-visible:text-amber-950",
    span: "origin-left scale-x-0 group-hover:scale-x-100 group-focus-visible:scale-x-100",
    ring: "focus-visible:ring-amber-500/60",
  },
];

function FillButton({ variant }: { variant: Variant }) {
  const [clicked, setClicked] = useState(false);

  return (
    <button
      type="button"
      onClick={() => {
        setClicked(true);
        window.setTimeout(() => setClicked(false), 900);
      }}
      aria-pressed={clicked}
      className={[
        "group relative inline-flex w-full items-center justify-center overflow-hidden",
        "rounded-xl border-2 bg-white px-6 py-3.5 text-sm font-semibold tracking-tight",
        "outline-none transition-[transform,box-shadow] duration-200 ease-out",
        "hover:-translate-y-0.5 active:translate-y-0",
        "focus-visible:ring-4 focus-visible:ring-offset-2",
        "focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
        "dark:bg-slate-900",
        variant.base,
        variant.ring,
      ].join(" ")}
    >
      <span
        aria-hidden="true"
        className={[
          "absolute inset-0 z-0 transform-gpu transition-transform duration-500",
          "ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
          "motion-reduce:group-hover:scale-100",
          variant.fill,
          variant.span,
        ].join(" ")}
      />
      <span
        className={[
          "relative z-10 flex items-center gap-2 transition-colors duration-300",
          "motion-reduce:transition-none",
          variant.fillText,
        ].join(" ")}
      >
        <span
          aria-hidden="true"
          className={
            "hbf-tick h-2 w-2 rounded-full bg-current opacity-70 " +
            (clicked ? "hbf-pulse" : "")
          }
        />
        {clicked ? "Confirmed" : variant.label}
      </span>
    </button>
  );
}

export default function HoverButtonFill() {
  return (
    <section className="relative w-full bg-slate-50 px-6 py-20 dark:bg-slate-950 sm:px-8 lg:py-28">
      <style>{`
        @keyframes hbf-pulse-kf {
          0%   { transform: scale(1);   opacity: 0.7; }
          40%  { transform: scale(2.4); opacity: 0;   }
          100% { transform: scale(1);   opacity: 0.7; }
        }
        .hbf-pulse { animation: hbf-pulse-kf 0.9s ease-out; }
        @media (prefers-reduced-motion: reduce) {
          .hbf-pulse { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-4xl">
        <div className="mb-12 max-w-2xl">
          <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-indigo-500" />
            Hover interaction
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            Colour-fill buttons
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            The fill sweeps in on hover and focus, from any direction. Keyboard
            focus triggers the same motion, and it collapses gracefully for
            reduced-motion users. Click to confirm.
          </p>
        </div>

        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {VARIANTS.map((variant) => (
            <div key={variant.id} className="flex flex-col gap-2">
              <FillButton variant={variant} />
              <span className="pl-1 text-xs font-medium text-slate-400 dark:text-slate-500">
                {variant.hint}
              </span>
            </div>
          ))}
        </div>

        <p className="mt-10 text-xs text-slate-400 dark:text-slate-600">
          Tab through the buttons — every fill animation runs on{" "}
          <code className="rounded bg-slate-200 px-1 py-0.5 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
            :focus-visible
          </code>{" "}
          too, not hover alone.
        </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 →