react-liquid-fillv0.1.1Docsnpm i react-liquid-fill

React component · hook · zero dependencies

Liquid Fill

Pour colour into icons, buttons and text. react-liquid-fill stacks two versions of anything and reveals the top one with an animated liquid edge, on hover, focus, scroll, or your own trigger. Hover the strip below.

Installation

Requires React 18 or newer. Works with Next.js (App and Pages Router), Vite, Remix and any other React setup. It ships no CSS file and has no dependencies.

bash
npm install react-liquid-fill

Quick start

Give LiquidFill a base that is always visible and a fill to pour over it. Hover and keyboard focus work straight away.

tsx
import { LiquidFill } from "react-liquid-fill";

export function Logo() {
  return (
    <LiquidFill
      base={<img src="/logo-outline.svg" alt="" width={48} height={48} />}
      fill={<img src="/logo.svg" alt="" width={48} height={48} />}
      aria-label="Acme"
      role="img"
    />
  );
}

On hover

The default, and what the strip at the top uses. The liquid pours in from whichever side the pointer enters and drains when it leaves. Each logo is two image files: an outline and the full-colour logo. The strip itself is a small marquee from the example project; it slows to a stop under the mouse.

tsx
import { LiquidFill } from "react-liquid-fill";

const icons = [{ name: "TypeScript", slug: "typescript" } /* … */];

<Marquee>
  {icons.map(({ name, slug }) => (
    <LiquidFill
      key={slug}
      className="tech"
      role="img"
      aria-label={name}
      tabIndex={0}
      base={<img src={`/icons/outline/${slug}.svg`} alt="" width={48} height={48} />}
      fill={<img src={`/icons/filled/${slug}.svg`} alt="" width={48} height={48} />}
    >
      <span className="tech-name">{name}</span>
    </LiquidFill>
  ))}
</Marquee>

On scroll

Set trigger="inView" and each item fills as it scrolls into view, rising from the bottom. Scroll back up and they drain. Use inView.delay to stagger a grid, or inView.once to keep them full.

Show code
tsx
<LiquidFill
  trigger="inView"
  inView={{ delay: index * 70 }}   // stagger a grid
  base={<img src={outline} alt="" />}
  fill={<img src={filled} alt="" />}
/>

On anything

Nothing is tied to icons. The heading uses the hook with trigger="manual" and calls setProgress() as you scroll. The button is LiquidFill rendered as a button with two copies of its label.

Scrubbed by scroll position.

Show code
tsx
import { useEffect, useRef } from "react";
import { useLiquidFill } from "react-liquid-fill";

export function ScrubbedHeading({ children }: { children: string }) {
  const el = useRef<HTMLHeadingElement>(null);
  const liquid = useLiquidFill({ ref: el, trigger: "manual", direction: "right" });

  useEffect(() => {
    const onScroll = () => {
      const top = el.current!.getBoundingClientRect().top;
      const vh = window.innerHeight;
      liquid.setProgress((vh * 0.85 - top) / (vh * 0.5));
    };
    onScroll();
    addEventListener("scroll", onScroll, { passive: true });
    return () => removeEventListener("scroll", onScroll);
  }, [liquid.setProgress]);

  return (
    <h2 ref={liquid.ref} className="stack">
      <span className="outlined">{children}</span>
      <span style={{ clipPath: "var(--liquid-clip, inset(0 100% 0 0))" }}>{children}</span>
    </h2>
  );
}
Show code
tsx
<LiquidFill
  as="button"
  type="button"
  touch="tap-toggle"
  base={<span className="btn">Get in touch ↗</span>}
  fill={<span className="btn btn-inverted">Get in touch ↗</span>}
/>

Use it with any icon set

react-liquid-fill doesn't ship icons and doesn't care where yours come from. You need two renderings of the same thing: a quiet base and a coloured fill. How you make them depends on the set. Hover each example.

Image files

Two artworks per icon: an outline and a full-colour version. This is what the strip above uses, with Devicon logos.

tsx
// Two files per icon, e.g. from Devicon or your designer
<LiquidFill
  base={<img src="/icons/outline/python.svg" alt="" width={48} height={48} />}
  fill={<img src="/icons/filled/python.svg" alt="" width={48} height={48} />}
/>

SVG icon components

react-icons, Simple Icons, MUI icons: render the same component twice with a muted and a brand colour.

tsx
// Any SVG icon component that takes a colour
import { SiReact } from "react-icons/si";

<LiquidFill
  base={<SiReact size={48} color="#c9c9c4" />}
  fill={<SiReact size={48} color="#149ECA" />}
/>

Stroke icon sets

Lucide, Heroicons, Phosphor: keep the outline as the base and add a fill colour to the top layer.

tsx
// Stroke icon sets (Lucide, Heroicons, Phosphor…)
import { Heart } from "lucide-react";

<LiquidFill
  direction="up"
  base={<Heart size={48} strokeWidth={1.5} color="#9a9a93" />}
  fill={<Heart size={48} strokeWidth={1.5} color="#e5484d" fill="#e5484d" />}
/>
🔥

Emoji and text

Anything that renders works. Here the base is a greyed-out copy made with a CSS filter.

tsx
// Emoji, text, or anything else that renders
<LiquidFill
  base={<span style={{ fontSize: 44, filter: "grayscale(1)", opacity: 0.4 }}>🔥</span>}
  fill={<span style={{ fontSize: 44 }}>🔥</span>}
/>

Need outline and colour versions of brand logos? The demo strip uses Devicon's full-colour logos, with the outline generated from the same file by re-styling every shape to a white fill and a thin stroke. The script that does this is in the example project.

Using the hook

LiquidFill is a thin wrapper. When you need your own markup, use useLiquidFill directly: attach ref and bind to an element, and clip whatever should be revealed with the --liquid-clip variable the hook animates.

tsx
import { useLiquidFill } from "react-liquid-fill";

export function NavLink({ href, children }) {
  const liquid = useLiquidFill(); // hover + keyboard focus

  return (
    <a href={href} ref={liquid.ref} {...liquid.bind} className="stack">
      <span>{children}</span>
      <span className="inverted" style={{ clipPath: "var(--liquid-clip, inset(0 100% 0 0))" }}>
        {children}
      </span>
    </a>
  );
}

Styling and dark mode

There is no stylesheet to import and no theme provider. The library only writes one CSS variable and two data attributes, so you style everything with your own CSS, Tailwind or CSS-in-JS.

css
/* The hook writes --liquid-clip on the host; clip whatever should be revealed */
.my-fill-layer {
  clip-path: var(--liquid-clip, inset(0 100% 0 0));
}

/* React to the finished state */
.my-icon[data-filled] .caption { color: var(--text-strong); }

/* Dark mode: invert a white outline artwork */
@media (prefers-color-scheme: dark) {
  [data-liquid-layer="base"] img { filter: invert(0.9); }
}

Accessibility

  • The fill layer is rendered with aria-hidden, so screen readers announce the content once.
  • The default triggers include keyboard focus. Add tabIndex={0} to non-interactive elements you want reachable.
  • Users who turn on reduced motion get an instant change instead of the animation. Set reducedMotion="ignore" to opt out.
  • Touch screens have no hover, so nothing happens by default. Set touch="tap-toggle" to fill on tap.

<LiquidFill />

tsx
import { LiquidFill } from "react-liquid-fill";
NameTypeDefaultDescription
baseReactNode–Always visible. Usually the outline, monochrome or inactive version.
fillReactNode–Revealed by the liquid. Usually the coloured or active version. Rendered with aria-hidden.
childrenReactNode–Rendered after the two stacked layers, inside the hover area. Use it for a caption.
asElementType"span"Element or component to render as, e.g. "button", "a", "li".
layersClassNamestring–Class for the inner element that stacks base and fill.
…optionsUseLiquidFillOptions–Every useLiquidFill option below (trigger, direction, duration…) is accepted as a prop.
…restHTMLAttributes–Any other prop (className, style, aria-*, data-*, event handlers) goes to the root element. Your handlers run alongside the library's.

useLiquidFill(options)

tsx
import { useLiquidFill } from "react-liquid-fill";

Options

NameTypeDefaultDescription
trigger"hover" | "focus" | "inView" | "manual" | Array["hover", "focus"]What starts the fill. "manual" means only your own fill(), drain() or setProgress() calls.
direction"auto" | "up" | "down" | "left" | "right" | { x, y }"auto"The way the liquid travels. "auto" pours from the side the pointer entered, and rises ("up") for every other trigger.
durationnumber | { in: number; out: number }{ in: 460, out: 340 }Milliseconds for a full fill and a full drain. A partial fill takes proportionally less.
easing(t: number) => numbereaseOutCubicMaps linear time (0–1) to progress (0–1).
wave{ amplitude?, ripple?, segments? } | false{ amplitude: 0.3, ripple: 21, segments: 21 }Shape of the leading edge. amplitude is a fraction of the shorter side; ripple is how fast it moves; false gives a straight wipe.
cssVarstring"--liquid-clip"The CSS custom property that gets animated. Rename it to nest fills or run two on one element.
inView{ threshold?, rootMargin?, once?, delay? }{ threshold: 0.6, rootMargin: "0px 0px -8% 0px", once: false, delay: 0 }Settings for the "inView" trigger. once keeps it full after the first fill; delay (ms) is handy for staggering a grid.
touch"ignore" | "tap-toggle""ignore"Touch screens have no hover. "tap-toggle" fills and drains on tap.
reducedMotion"respect" | "ignore""respect"With "respect", users who prefer reduced motion see an instant change instead of the animation.
disabledbooleanfalseDrains and ignores every trigger while true.
initial0 | 10Start empty or full.
measureRefRefObject<Element>the host elementSize the wave from another element, for when the revealed layer is smaller than the host. LiquidFill sets this for you.
refRef<T>–Your own ref to the same element. The hook keeps it in sync.
onProgress(progress: number) => void–Called every frame while animating, and on setProgress().
onFilled() => void–Called when a fill completes.
onDrained() => void–Called when a drain completes.

Returns

NameTypeDescription
ref(node: T | null) => voidAttach to the host element.
bind{ onPointerEnter, onPointerLeave, onPointerDown, onFocus, onBlur }Event handlers for the triggers you enabled. Spread them onto the host.
fill(from?: direction | { clientX, clientY }) => voidPour in. Pass a direction, or a pointer position to pour from that side.
drain() => voidPour out.
toggle() => voidFill if less than half full, otherwise drain.
setProgress(progress: number, direction?) => voidJump to a progress between 0 and 1 with no animation. Use it to scrub the fill with scroll or a slider.
getProgress() => numberCurrent progress, read without re-rendering.
filledbooleantrue after a fill completes, false after a drain completes. Re-renders only when an animation settles.

liquidPolygon(input)

The geometry on its own, with no React. Returns a CSS polygon() string, or inset() at 0 and 1. Use it with vanilla JS, canvas clipping, or another framework.

tsx
import { liquidPolygon } from "react-liquid-fill";

el.style.clipPath = liquidPolygon({
  progress: 0.5,
  direction: { x: 0, y: -1 },
  width: el.offsetWidth,
  height: el.offsetHeight,
});
NameTypeDefaultDescription
progressnumber–0 is empty, 1 is full.
direction{ x: number; y: number }–Unit vector of travel. { x: 0, y: -1 } rises from the bottom.
width, heightnumber–Size of the box in pixels; used to keep the wave in proportion.
timenumber0Seconds since the animation began; drives the ripple.
waveWaveOptions | false{}Same as the hook's wave option.

Attributes and CSS

NameTypeDescription
data-filledboolean attributeSet on the LiquidFill root while it is full. Style captions or borders with [data-filled].
data-liquid-layer="base"attributeThe base layer wrapper.
data-liquid-layer="fill"attributeThe fill layer wrapper. It carries the clip-path.
--liquid-clipCSS custom propertyWritten on the host element every frame. Clip anything with clip-path: var(--liquid-clip, inset(0 100% 0 0)).