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-buttonUsage
Usagetsx
<ShimmerButton />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | enum | default | Visual base: filled, animated-border outline, or muted ghost. |
| size | enum | md | Height/padding scale: sm=32px, md=40px, lg=48px. |
| shimmerDuration | number | 2200 | Full sheen sweep cycle in ms. Lower = faster, higher = ambient. |
| shimmerWidth | number | 25 | Width of the travelling highlight as a percent of button width. |
| asChild | boolean | false | Render 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