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.
TypeScript
JavaScript
Python
React
Next.js
Angular
Vue
Rust
Tailwind CSS
Node.js
Bun
Vite
Docker
PostgreSQL
Supabase
Redis
FastAPI
Django
LangChain
PyTorch
Vitest
Playwright
GitHub Actions
Vercel
TypeScript
JavaScript
Python
React
Next.js
Angular
Vue
Rust
Tailwind CSS
Node.js
Bun
Vite
Docker
PostgreSQL
Supabase
Redis
FastAPI
Django
LangChain
PyTorch
Vitest
Playwright
GitHub Actions
Vercel
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.
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.
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.
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.Scrubbed by scroll position.
<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.
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.
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";
Name
Type
Default
Description
base
ReactNode
–
Always visible. Usually the outline, monochrome or inactive version.
fill
ReactNode
–
Revealed by the liquid. Usually the coloured or active version. Rendered with aria-hidden.
children
ReactNode
–
Rendered after the two stacked layers, inside the hover area. Use it for a caption.
as
ElementType
"span"
Element or component to render as, e.g. "button", "a", "li".
layersClassName
string
–
Class for the inner element that stacks base and fill.
…options
UseLiquidFillOptions
–
Every useLiquidFill option below (trigger, direction, duration…) is accepted as a prop.
…rest
HTMLAttributes
–
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
Name
Type
Default
Description
trigger
"hover" | "focus" | "inView" | "manual" | Array
["hover", "focus"]
What starts the fill. "manual" means only your own fill(), drain() or setProgress() calls.
Event handlers for the triggers you enabled. Spread them onto the host.
fill
(from?: direction | { clientX, clientY }) => void
Pour in. Pass a direction, or a pointer position to pour from that side.
drain
() => void
Pour out.
toggle
() => void
Fill if less than half full, otherwise drain.
setProgress
(progress: number, direction?) => void
Jump to a progress between 0 and 1 with no animation. Use it to scrub the fill with scroll or a slider.
getProgress
() => number
Current progress, read without re-rendering.
filled
boolean
true 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.