Buttons
Holographic Foil Button
A foil-finished button whose spectrum slides against your pointer, blended on hue so the label stays legible throughout.
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/holographic-foil-button.json'use client';
import { useCallback, useRef } from 'react';
// Pointer position lives in CSS custom properties, not React state: the markup
// does not change as the pointer moves, so state would cost a render per frame
// to set two numbers.
const css = `
.foil {
/* Pointer position, 0..1, written by script. One source of truth so the
sheen and the rim light can never disagree by a frame. */
--fx: 0.5;
--fy: 0.5;
position: relative;
border: 0;
padding: 17px 40px;
border-radius: 14px;
font: 650 16px/1 system-ui, sans-serif;
letter-spacing: 0.01em;
color: #0a0714;
cursor: pointer;
overflow: hidden;
isolation: isolate;
background: linear-gradient(135deg, #fdf7ff 0%, #f2fbff 50%, #fff4fa 100%);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.6),
0 10px 26px rgba(40, 10, 60, 0.45);
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* The foil itself: a spectral sweep crossed with a finer light/dark sweep at a
different angle. Two crossed gradients give the patchy interference of real
foil; one rainbow reads as a printed stripe. */
.foil::before {
content: "";
position: absolute;
inset: -30%;
background-image:
repeating-linear-gradient(
108deg,
rgba(255, 60, 172, 0.85) 0 16px,
rgba(255, 214, 92, 0.85) 16px 32px,
rgba(74, 255, 208, 0.85) 32px 48px,
rgba(96, 140, 255, 0.85) 48px 64px,
rgba(255, 60, 172, 0.85) 64px 80px
),
repeating-linear-gradient(
58deg,
rgba(255, 255, 255, 0.3) 0 9px,
rgba(0, 0, 0, 0.3) 9px 18px
);
/* Opposed parallax: the pattern travels against the cursor, and further than
it moves. That is what puts the foil under the surface instead of on it. */
background-position:
calc(var(--fx) * -120px) calc(var(--fy) * -120px),
calc(var(--fx) * 70px) calc(var(--fy) * 70px);
mix-blend-mode: color;
opacity: 1;
/* Drifts on its own so the spectrum is already alive before the pointer
lands; the pointer then shifts it far further than this ever does. */
animation: foil-drift 11s linear infinite;
pointer-events: none;
transition: opacity 160ms ease;
}
/* A specular that sits exactly under the pointer, so the foil has a light
source rather than simply shimmering everywhere at once. */
.foil::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
42% 130% at calc(var(--fx) * 100%) calc(var(--fy) * 100%),
rgba(255, 255, 255, 0.85),
transparent 68%
);
mix-blend-mode: soft-light;
pointer-events: none;
}
.foil__label { position: relative; z-index: 2; }
/* A specular that travels on its own. Without it the button is inert until the
pointer arrives — and a gallery shows it at rest, so at rest is the frame
that has to work. Pointer response is the reward, not the baseline. */
.foil__sheen {
position: absolute;
inset: -40%;
z-index: 1;
background: linear-gradient(
100deg,
transparent 40%,
rgba(255, 255, 255, 0.85) 49%,
rgba(255, 255, 255, 0.3) 55%,
transparent 63%
);
transform: translateX(-60%);
animation: foil-sweep 5.2s ease-in-out infinite;
mix-blend-mode: overlay;
pointer-events: none;
}
@keyframes foil-sweep {
0%, 14% { transform: translateX(-60%); }
64%, 100% { transform: translateX(60%); }
}
.foil:hover { transform: translateY(-2px); }
.foil:hover::before { opacity: 1; }
.foil:active { transform: translateY(0); }
.foil:focus-visible {
outline: 2px solid #b45bff;
outline-offset: 3px;
}
@keyframes foil-drift {
from { background-position: calc(var(--fx) * -120px) calc(var(--fy) * -120px), calc(var(--fx) * 70px) calc(var(--fy) * 70px); }
to { background-position: calc(var(--fx) * -120px - 80px) calc(var(--fy) * -120px), calc(var(--fx) * 70px + 48px) calc(var(--fy) * 70px); }
}
@media (prefers-reduced-motion: reduce) {
.foil, .foil::before { transition: none; }
.foil__sheen, .foil::before { animation: none; }
}
`;
export function HolographicFoilButton({ children = 'Claim your foil' }: { children?: React.ReactNode }) {
const ref = useRef<HTMLButtonElement>(null);
const track = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
const button = ref.current;
if (!button) return;
const rect = button.getBoundingClientRect();
button.style.setProperty('--fx', String((event.clientX - rect.left) / rect.width));
button.style.setProperty('--fy', String((event.clientY - rect.top) / rect.height));
}, []);
const reset = useCallback(() => {
const button = ref.current;
if (!button) return;
button.style.setProperty('--fx', '0.5');
button.style.setProperty('--fy', '0.5');
}, []);
return (
<>
<style>{css}</style>
<button className="foil" type="button" ref={ref} onPointerMove={track} onPointerLeave={reset}>
<span className="foil__sheen" />
<span className="foil__label">{children}</span>
</button>
</>
);
}