Skip to content
Plinth

Buttons

Holographic Foil Button

A foil-finished button whose spectrum slides against your pointer, blended on hue so the label stays legible throughout.

#button#holographic#foil#pointer#blend-mode

Install

npx shadcn@latest add https://plinthui.com/r/holographic-foil-button.json
Open in v0
'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>
    </>
  );
}

More in Buttons

Similar tags