Web InnoventixFreeCode

Icon Fill Hover Effect

Original · free

Icon buttons whose icon and background fill on hover.

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

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

type IconName =
  | "heart"
  | "star"
  | "bookmark"
  | "bell"
  | "share"
  | "download"
  | "play"
  | "link";

type IconButton = {
  id: string;
  name: IconName;
  label: string;
  activeLabel: string;
  fill: string;
  ring: string;
  text: string;
  darkText: string;
};

const ICONS: Record<IconName, (props: { className?: string }) => ReactElement> = {
  heart: ({ className }) => (
    <svg
      viewBox="0 0 24 24"
      fill="currentColor"
      stroke="currentColor"
      strokeWidth="1.75"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21.2l7.8-7.8 1-1a5.5 5.5 0 0 0 0-7.8Z" />
    </svg>
  ),
  star: ({ className }) => (
    <svg
      viewBox="0 0 24 24"
      fill="currentColor"
      stroke="currentColor"
      strokeWidth="1.75"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M12 2.5l2.9 5.9 6.5.95-4.7 4.58 1.1 6.47L12 17.4 6.2 20.9l1.1-6.47-4.7-4.58 6.5-.95L12 2.5Z" />
    </svg>
  ),
  bookmark: ({ className }) => (
    <svg
      viewBox="0 0 24 24"
      fill="currentColor"
      stroke="currentColor"
      strokeWidth="1.75"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M6 3.5h12a1 1 0 0 1 1 1v16l-7-4.2-7 4.2v-16a1 1 0 0 1 1-1Z" />
    </svg>
  ),
  bell: ({ className }) => (
    <svg
      viewBox="0 0 24 24"
      fill="currentColor"
      stroke="currentColor"
      strokeWidth="1.75"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M6 9a6 6 0 0 1 12 0c0 5 2 6.5 2 6.5H4S6 14 6 9Z" />
      <path d="M10.5 19.5a2 2 0 0 0 3 0" fill="none" />
    </svg>
  ),
  share: ({ className }) => (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.85"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <circle cx="6" cy="12" r="2.6" fill="currentColor" />
      <circle cx="18" cy="6" r="2.6" fill="currentColor" />
      <circle cx="18" cy="18" r="2.6" fill="currentColor" />
      <path d="M8.3 10.8 15.7 7.2M8.3 13.2l7.4 3.6" />
    </svg>
  ),
  download: ({ className }) => (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.85"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M12 3.5v11" />
      <path d="m7.5 10.5 4.5 4.5 4.5-4.5" />
      <path d="M4.5 19.5h15" />
    </svg>
  ),
  play: ({ className }) => (
    <svg
      viewBox="0 0 24 24"
      fill="currentColor"
      stroke="currentColor"
      strokeWidth="1.6"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M8 5.2 19 12 8 18.8V5.2Z" />
    </svg>
  ),
  link: ({ className }) => (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.85"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M9.5 14.5a3.5 3.5 0 0 0 5 0l3-3a3.54 3.54 0 0 0-5-5l-1 1" />
      <path d="M14.5 9.5a3.5 3.5 0 0 0-5 0l-3 3a3.54 3.54 0 0 0 5 5l1-1" />
    </svg>
  ),
};

const BUTTONS: IconButton[] = [
  {
    id: "like",
    name: "heart",
    label: "Like this post",
    activeLabel: "Liked",
    fill: "bg-rose-500",
    ring: "focus-visible:ring-rose-500/60 group-hover:border-rose-500/70",
    text: "text-rose-600",
    darkText: "dark:text-rose-400",
  },
  {
    id: "favorite",
    name: "star",
    label: "Add to favorites",
    activeLabel: "Favorited",
    fill: "bg-amber-400",
    ring: "focus-visible:ring-amber-400/60 group-hover:border-amber-400/70",
    text: "text-amber-500",
    darkText: "dark:text-amber-300",
  },
  {
    id: "save",
    name: "bookmark",
    label: "Save for later",
    activeLabel: "Saved",
    fill: "bg-indigo-500",
    ring: "focus-visible:ring-indigo-500/60 group-hover:border-indigo-500/70",
    text: "text-indigo-600",
    darkText: "dark:text-indigo-400",
  },
  {
    id: "subscribe",
    name: "bell",
    label: "Turn on alerts",
    activeLabel: "Alerts on",
    fill: "bg-emerald-500",
    ring: "focus-visible:ring-emerald-500/60 group-hover:border-emerald-500/70",
    text: "text-emerald-600",
    darkText: "dark:text-emerald-400",
  },
  {
    id: "share",
    name: "share",
    label: "Share",
    activeLabel: "Shared",
    fill: "bg-sky-500",
    ring: "focus-visible:ring-sky-500/60 group-hover:border-sky-500/70",
    text: "text-sky-600",
    darkText: "dark:text-sky-400",
  },
  {
    id: "download",
    name: "download",
    label: "Download file",
    activeLabel: "Downloaded",
    fill: "bg-violet-500",
    ring: "focus-visible:ring-violet-500/60 group-hover:border-violet-500/70",
    text: "text-violet-600",
    darkText: "dark:text-violet-400",
  },
  {
    id: "play",
    name: "play",
    label: "Play preview",
    activeLabel: "Playing",
    fill: "bg-emerald-500",
    ring: "focus-visible:ring-emerald-500/60 group-hover:border-emerald-500/70",
    text: "text-emerald-600",
    darkText: "dark:text-emerald-400",
  },
  {
    id: "copy",
    name: "link",
    label: "Copy link",
    activeLabel: "Copied",
    fill: "bg-sky-500",
    ring: "focus-visible:ring-sky-500/60 group-hover:border-sky-500/70",
    text: "text-sky-600",
    darkText: "dark:text-sky-400",
  },
];

export default function HoverIconFill() {
  const [active, setActive] = useState<Record<string, boolean>>({});

  const toggle = (id: string) =>
    setActive((prev) => ({ ...prev, [id]: !prev[id] }));

  return (
    <section className="relative w-full bg-white px-6 py-20 text-slate-900 sm:px-10 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes hif-pop {
          0%   { transform: scale(1); }
          40%  { transform: scale(0.86); }
          70%  { transform: scale(1.12); }
          100% { transform: scale(1); }
        }
        .hif-pop { animation: hif-pop 360ms cubic-bezier(0.34, 1.56, 0.64, 1); }
        @media (prefers-reduced-motion: reduce) {
          .hif-pop { animation: none !important; }
          .hif-fill, .hif-icon, .hif-tile { transition: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <div className="mb-14 max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium tracking-wide 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" />
            Interactive
          </span>
          <h2 className="mt-5 text-3xl font-semibold tracking-tight sm:text-4xl">
            Icon buttons that fill on hover
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Hover to watch the color sweep in and the glyph flip to white. Click
            to commit the action — the fill locks, the icon pops, and the label
            updates. Fully keyboard and screen-reader friendly.
          </p>
        </div>

        <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
          {BUTTONS.map((btn) => {
            const Icon = ICONS[btn.name];
            const isOn = !!active[btn.id];
            return (
              <button
                key={btn.id}
                type="button"
                onClick={() => toggle(btn.id)}
                aria-pressed={isOn}
                aria-label={isOn ? btn.activeLabel : btn.label}
                className={`hif-tile group relative flex flex-col items-center gap-3 overflow-hidden rounded-2xl border border-slate-200 bg-white px-4 py-6 text-center outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-800 dark:bg-slate-900 dark:focus-visible:ring-offset-slate-950 ${btn.ring}`}
              >
                <span
                  className={`hif-fill pointer-events-none absolute inset-0 origin-bottom scale-y-0 rounded-2xl transition-transform duration-300 ease-out group-hover:scale-y-100 ${btn.fill} ${
                    isOn ? "scale-y-100" : ""
                  }`}
                  aria-hidden="true"
                />
                <span
                  className={`hif-icon relative flex h-12 w-12 items-center justify-center rounded-xl transition-colors duration-300 ${
                    isOn
                      ? "text-white"
                      : `${btn.text} ${btn.darkText} group-hover:text-white`
                  }`}
                >
                  <Icon
                    className={`h-6 w-6 transition-transform duration-300 group-hover:scale-110 ${
                      isOn ? "hif-pop" : ""
                    }`}
                  />
                </span>
                <span
                  className={`relative text-sm font-medium transition-colors duration-300 ${
                    isOn
                      ? "text-white"
                      : "text-slate-700 group-hover:text-white dark:text-slate-300"
                  }`}
                >
                  {isOn ? btn.activeLabel : btn.label}
                </span>
              </button>
            );
          })}
        </div>

        <p className="mt-10 text-sm text-slate-500 dark:text-slate-500">
          Tip: focus a button with Tab and press Enter or Space — the fill and
          state toggle exactly as on click.
        </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 →