ShimmerButton

One focal call to action with a light sweeping across it, filled from the seam so the loudest element on the page introduces no third colour.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/shimmer-button.json
CSSno JS
filled from the seam

ShimmerButtonesc or double-click to exit full screendouble-tap to exit

primitives/shimmer-button.tsx
"use client";

import * as React from "react";
import { cx, useTokenColorExpr } from "@pouriahlabs/wisp-ui";

/**
 * ShimmerButton — one focal call to action, with a light sweeping across it.
 *
 * The fill defaults to the seam, so the page's single loudest element is made
 * of the same two tokens as everything else rather than introducing a third
 * colour. Even the travelling highlight is a token (`--wisp-shimmer`) rather
 * than a hardcoded white, so it re-tunes with the palette. Override
 * `background` or `shimmerColor` if your CTA needs its own.
 *
 * Renders an `<a>` when given `href` and a `<button>` otherwise, so the element
 * matches what it actually does — a link that looks like a button but is not one
 * breaks middle-click, and a button that is an anchor breaks the Enter/Space
 * contract. For a framework link — Next's `<Link>`, a router's `<NavLink>` —
 * pass `asChild` and that single child becomes the element, so client-side
 * navigation keeps working where a plain `<a href>` would force a full reload.
 *
 * The sweep stops under reduced motion; the button keeps its fill and label,
 * because the shimmer was decoration and the CTA is the information.
 */

type Common = {
  className?: string;
  children: React.ReactNode;
  /** Any CSS background. Defaults to the seam gradient. */
  background?: string;
  /** Colour of the travelling highlight. Defaults to the `--wisp-shimmer` token. */
  shimmerColor?: string;
  /** Milliseconds per sweep. */
  shimmerDuration?: number;
  /**
   * Render the single child element as the button, merging Wisp's classes and
   * fill onto it, instead of emitting an `<a>`/`<button>`. The escape hatch for
   * framework links that must own their own element.
   */
  asChild?: boolean;
};

export type ShimmerButtonProps = Common &
  (
    | ({ href: string } & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof Common>)
    | ({ href?: undefined } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof Common>)
  );

export const ShimmerButton = React.forwardRef<HTMLElement, ShimmerButtonProps>(
  function ShimmerButton(
    { className, children, background, shimmerColor, shimmerDuration = 2400, asChild = false, href, ...props },
    ref,
  ) {
    const from = useTokenColorExpr("--wisp");
    const to = useTokenColorExpr("--lantern");
    const shimmer = useTokenColorExpr("--wisp-shimmer");

    const classes = cx("wisp-shimmer-button", className);
    const style = {
      background: background ?? `linear-gradient(120deg, ${from}, ${to})`,
      "--wisp-shimmer-color": shimmerColor ?? shimmer,
      "--wisp-shimmer-duration": `${shimmerDuration}ms`,
    } as React.CSSProperties;

    // The sweep is decorative; the label carries the accessible name.
    const decorate = (label: React.ReactNode) => (
      <>
        <span aria-hidden className="wisp-shimmer-sweep" />
        <span className="wisp-shimmer-label">{label}</span>
      </>
    );

    if (asChild && React.isValidElement(children)) {
      const child = children as React.ReactElement<Record<string, unknown>>;
      const childProps = child.props;
      return React.cloneElement(
        child,
        {
          className: cx(classes, childProps.className as string | undefined),
          style: { ...style, ...(childProps.style as React.CSSProperties | undefined) },
          ref,
          ...props,
        },
        decorate(childProps.children as React.ReactNode),
      );
    }

    if (href !== undefined) {
      return (
        <a
          ref={ref as React.Ref<HTMLAnchorElement>}
          href={href}
          className={classes}
          style={style}
          {...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
        >
          {decorate(children)}
        </a>
      );
    }

    return (
      <button
        ref={ref as React.Ref<HTMLButtonElement>}
        type="button"
        className={classes}
        style={style}
        {...(props as React.ButtonHTMLAttributes<HTMLButtonElement>)}
      >
        {decorate(children)}
      </button>
    );
  },
);

Certified against the contract