眩光悬停
New悬停时一道斜向光带扫过卡片或按钮表面。光色、角度、时长可调;触屏设备不渲染,「减少动效」偏好下换成轻微透明度高亮。
TSXcomponents/previews/motion/glare-hover.preview.tsx
"use client";
import { GlareHover } from "@/components/motion/glare-hover";
export function GlareHoverPreview() {
return (
<div className="flex flex-wrap items-center justify-center gap-10 p-6">
<GlareHover className="w-64 rounded-2xl border border-border shadow-lg">
{/* biome-ignore lint/performance/noImgElement: plain img keeps the copy-paste preview portable (no next/image host config). */}
<img
src="https://picsum.photos/seed/uilab-glare-hover/480/320"
alt=""
className="block aspect-[3/2] w-full object-cover"
/>
</GlareHover>
<GlareHover className="rounded-full" angle={-25} duration={450}>
<button
type="button"
className="rounded-full bg-neutral-950 px-6 py-2.5 text-sm font-semibold text-white"
>
Get started
</button>
</GlareHover>
</div>
);
}
TSXcomponents/motion/glare-hover.tsx
// ui-lab-ten.vercel.app/components/motion/glare-hover
// Ported from motion-anything (nexu-io, Apache-2.0); upstream: reactbits.dev "Glare Hover", redistributed with permission.
"use client";
import { motion, useReducedMotion } from "motion/react";
import { useState, type ReactNode } from "react";
import { EASE_OUT_CSS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface GlareHoverProps {
/** Content the glare sweeps across. Give it its own opaque background (card, button, image, …) — the wrapper only paints the glare layer. */
children: ReactNode;
className?: string;
/** Color of the light band. Defaults to a soft translucent white. */
color?: string;
/** Angle of the sweeping band, in degrees. */
angle?: number;
/** Duration of one sweep, in ms. */
duration?: number;
/** Play the sweep once on first hover instead of replaying on every hover. */
playOnce?: boolean;
}
/**
* A diagonal light band sweeps across an element's surface on hover — a
* premium, tactile sheen for a card or button. Pure CSS: a skewed gradient
* layer sits above the content and translates across on hover via a
* transform transition.
*/
export function GlareHover({
children,
className,
color = "rgba(255, 255, 255, 0.55)",
angle = -30,
duration = 550,
playOnce = false,
}: GlareHoverProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative hover sheen: skip entirely on touch (phantom hover sticks after tap).
const enabled = canHover;
const [hovered, setHovered] = useState(false);
const [played, setPlayed] = useState(false);
const active = enabled && (hovered || (playOnce && played));
const handleEnter = () => {
if (!enabled) return;
setHovered(true);
if (playOnce) setPlayed(true);
};
const handleLeave = () => {
if (!enabled) return;
setHovered(false);
};
return (
<motion.div
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
className={cn("relative isolate overflow-hidden", className)}
>
{children}
{enabled ? (
<span
aria-hidden
className="pointer-events-none absolute inset-[-50%] will-change-[transform,opacity]"
style={
reduce
? {
background: color,
opacity: active ? 0.35 : 0,
transitionProperty: "opacity",
transitionDuration: `${duration}ms`,
transitionTimingFunction: EASE_OUT_CSS,
}
: {
background: `linear-gradient(${angle}deg, transparent 40%, ${color} 50%, transparent 60%)`,
transform: active ? "translateX(150%)" : "translateX(-150%)",
transitionProperty: "transform",
transitionDuration: `${duration}ms`,
transitionTimingFunction: EASE_OUT_CSS,
}
}
/>
) : null}
</motion.div>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/glare-hover
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
TSXlib/hooks/use-hover-capable.ts
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/glare-hover.tsx
// ui-lab-ten.vercel.app/components/motion/glare-hover
// Ported from motion-anything (nexu-io, Apache-2.0); upstream: reactbits.dev "Glare Hover", redistributed with permission.
"use client";
import { motion, useReducedMotion } from "motion/react";
import { useState, type ReactNode } from "react";
import { EASE_OUT_CSS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface GlareHoverProps {
/** Content the glare sweeps across. Give it its own opaque background (card, button, image, …) — the wrapper only paints the glare layer. */
children: ReactNode;
className?: string;
/** Color of the light band. Defaults to a soft translucent white. */
color?: string;
/** Angle of the sweeping band, in degrees. */
angle?: number;
/** Duration of one sweep, in ms. */
duration?: number;
/** Play the sweep once on first hover instead of replaying on every hover. */
playOnce?: boolean;
}
/**
* A diagonal light band sweeps across an element's surface on hover — a
* premium, tactile sheen for a card or button. Pure CSS: a skewed gradient
* layer sits above the content and translates across on hover via a
* transform transition.
*/
export function GlareHover({
children,
className,
color = "rgba(255, 255, 255, 0.55)",
angle = -30,
duration = 550,
playOnce = false,
}: GlareHoverProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative hover sheen: skip entirely on touch (phantom hover sticks after tap).
const enabled = canHover;
const [hovered, setHovered] = useState(false);
const [played, setPlayed] = useState(false);
const active = enabled && (hovered || (playOnce && played));
const handleEnter = () => {
if (!enabled) return;
setHovered(true);
if (playOnce) setPlayed(true);
};
const handleLeave = () => {
if (!enabled) return;
setHovered(false);
};
return (
<motion.div
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
className={cn("relative isolate overflow-hidden", className)}
>
{children}
{enabled ? (
<span
aria-hidden
className="pointer-events-none absolute inset-[-50%] will-change-[transform,opacity]"
style={
reduce
? {
background: color,
opacity: active ? 0.35 : 0,
transitionProperty: "opacity",
transitionDuration: `${duration}ms`,
transitionTimingFunction: EASE_OUT_CSS,
}
: {
background: `linear-gradient(${angle}deg, transparent 40%, ${color} 50%, transparent 60%)`,
transform: active ? "translateX(150%)" : "translateX(-150%)",
transitionProperty: "transform",
transitionDuration: `${duration}ms`,
transitionTimingFunction: EASE_OUT_CSS,
}
}
/>
) : null}
</motion.div>
);
}
API 参考
childrenReactNodeContent the glare sweeps across. Give it its own opaque background (card, button, image, …) — the wrapper only paints the glare layer.
—className?string—color?stringColor of the light band. Defaults to a soft translucent white.
rgba(255, 255, 255, 0.55)angle?numberAngle of the sweeping band, in degrees.
-30duration?numberDuration of one sweep, in ms.
550playOnce?booleanPlay the sweep once on first hover instead of replaying on every hover.
falseKeep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.