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.jsonCSSno JS
filled from the seam
"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
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React