bottega

primitives

Shimmer Button

A button with a travelling sheen — a narrow blurred highlight that sweeps diagonally across the face on an idle loop, accelerates on hover, and snaps back on press. The outline variant ignites a slow conic-gradient perimeter glow that floats off the surface and speeds up on hover. Pure-CSS sheen via a mask trick (no canvas, no extra DOM), so it stays compositable over any background. Extends the native button (default/outline/ghost variants, sm/md/lg sizes, asChild delegation); fully keyboard accessible with a focus-visible ring.


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/shimmer-button

Usage

Usagetsx
<ShimmerButton />

Props

PropTypeDefaultDescription
variantenumdefaultVisual base: filled, animated-border outline, or muted ghost.
sizeenummdHeight/padding scale: sm=32px, md=40px, lg=48px.
shimmerDurationnumber2200Full sheen sweep cycle in ms. Lower = faster, higher = ambient.
shimmerWidthnumber25Width of the travelling highlight as a percent of button width.
asChildbooleanfalseRender as the single child element (for link/router delegation), shadcn-style.

Source

shimmer-button.tsxtsx
"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import styles from "./shimmer-button.module.css";

export type ShimmerButtonVariant = "default" | "outline" | "ghost";
export type ShimmerButtonSize = "sm" | "md" | "lg";

export interface ShimmerButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Visual base: filled, animated-border outline, or muted ghost. */
  variant?: ShimmerButtonVariant;
  /** Height/padding scale: sm=32px, md=40px, lg=48px. */
  size?: ShimmerButtonSize;
  /** Full sheen sweep cycle in ms. Lower = faster, higher = ambient. */
  shimmerDuration?: number;
  /** Width of the travelling highlight as a percent of button width. */
  shimmerWidth?: number;
  /** Render as the single child element (for link/router delegation), shadcn-style. */
  asChild?: boolean;
}

const VARIANT_CLASS: Record<ShimmerButtonVariant, string> = {
  // sheen overlay is additive light, so text contrast is preserved
  default: "bg-primary text-primary-foreground",
  outline: "bg-background text-foreground",
  ghost: "bg-muted text-foreground hover:bg-accent",
};

const SIZE_CLASS: Record<ShimmerButtonSize, string> = {
  sm: "h-8 px-3 text-xs gap-1.5",
  md: "h-10 px-5 text-sm gap-2",
  lg: "h-12 px-7 text-base gap-2",
};

/** Minimal asChild Slot — merges props onto the single child without a wrapper
 *  DOM node, so the component drops into any layout with no reflow. No external
 *  dependency needed for this single, well-defined merge. */
function mergeProps(
  child: React.ReactElement<Record<string, unknown>>,
  slotProps: Record<string, unknown>,
): Record<string, unknown> {
  const childProps = child.props;
  const merged: Record<string, unknown> = { ...slotProps, ...childProps };
  const childClass = childProps.className as string | undefined;
  const slotClass = slotProps.className as string | undefined;
  merged.className = [slotClass, childClass].filter(Boolean).join(" ");
  const childStyle = childProps.style as React.CSSProperties | undefined;
  const slotStyle = slotProps.style as React.CSSProperties | undefined;
  merged.style = { ...slotStyle, ...childStyle };
  return merged;
}

export function ShimmerButton({
  children,
  variant = "default",
  size = "md",
  shimmerDuration = 2200,
  shimmerWidth = 25,
  asChild = false,
  disabled = false,
  className = "",
  style,
  type,
  onClick,
  onKeyDown,
  ...props
}: ShimmerButtonProps) {
  // Mount-gate + null-safe reduce check (contract snippet) so SSR/first paint
  // is static (no #418 hydration mismatch) and reduced-motion users get no flash.
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion;
  const animate = mounted && !reduce && !disabled;

  const classes = [
    styles.button,
    variant === "outline" && styles.outline,
    "relative inline-flex items-center justify-center select-none",
    "font-medium whitespace-nowrap",
    "rounded-[calc(var(--radius)-2px)]",
    "transition-transform duration-[var(--duration)] ease-[var(--ease)]",
    "active:scale-[0.97]",
    "cursor-pointer",
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
    "aria-disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:active:scale-100",
    VARIANT_CLASS[variant],
    SIZE_CLASS[size],
    className,
  ]
    .filter(Boolean)
    .join(" ");

  // aria-disabled (not the native HTML disabled attr) keeps the control
  // keyboard-focusable for discoverability, but on its own it leaves a native
  // <button> still activatable (Enter/Space/click) and form-submit-capable.
  // Suppress activation explicitly so a disabled button cannot fire onClick,
  // submit a form, or be triggered from the keyboard.
  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    if (disabled) {
      event.preventDefault();
      event.stopPropagation();
      return;
    }
    onClick?.(event);
  };
  const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
    if (disabled && (event.key === "Enter" || event.key === " ")) {
      event.preventDefault();
      event.stopPropagation();
      return;
    }
    onKeyDown?.(event);
  };

  const slotProps = {
    "data-animate": animate ? "true" : "false",
    "aria-disabled": disabled || undefined,
    // while disabled, force type="button" so it can't submit a form; otherwise
    // preserve the caller's (or native) type. Stays in the tab order — native
    // `disabled` would un-focus it.
    type: disabled ? "button" : type,
    onClick: handleClick,
    onKeyDown: handleKeyDown,
    className: classes,
    style: {
      ...style,
      "--shimmer-duration": `${shimmerDuration}ms`,
      "--shimmer-width": `${shimmerWidth}%`,
    } as React.CSSProperties,
    ...props,
  };

  if (asChild && React.isValidElement(children)) {
    const child = children as React.ReactElement<Record<string, unknown>>;
    return React.cloneElement(child, mergeProps(child, slotProps));
  }

  return (
    <button {...slotProps}>{children}</button>
  );
}

Dependencies

  • motion
  • @bottega/tokens