滚动提示
New首屏底部的向下滚动指示器,两种形态:鼠标轮廓内圆点下落,或两枚 chevron 依次脉冲。跟随 currentColor,可带文字标签,「减少动效」偏好下静态显示。
Mouse
Scroll
Mouse + labelChevron
Scroll
Chevron + labelTSXcomponents/previews/motion/scroll-hint.preview.tsx
"use client";
import { ScrollHint } from "@/components/motion/scroll-hint";
export function ScrollHintPreview() {
return (
<div className="flex flex-wrap items-center justify-center gap-10 p-8">
<div className="flex flex-col items-center gap-4">
<ScrollHint variant="mouse" />
<span className="text-xs text-muted-foreground">Mouse</span>
</div>
<div className="flex flex-col items-center gap-4">
<ScrollHint variant="mouse" label="Scroll" />
<span className="text-xs text-muted-foreground">Mouse + label</span>
</div>
<div className="flex flex-col items-center gap-4">
<ScrollHint variant="chevron" />
<span className="text-xs text-muted-foreground">Chevron</span>
</div>
<div className="flex flex-col items-center gap-4">
<ScrollHint variant="chevron" label="Scroll" />
<span className="text-xs text-muted-foreground">Chevron + label</span>
</div>
</div>
);
}
TSXcomponents/motion/scroll-hint.tsx
// ui-lab-ten.vercel.app/components/motion/scroll-hint
import { EASE_OUT_CSS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ScrollHintVariant = "mouse" | "chevron";
export interface ScrollHintProps {
/** Which glyph to render. */
variant?: ScrollHintVariant;
className?: string;
/** Optional caption under the glyph, e.g. "Scroll". */
label?: string;
}
/**
* A quiet "there's more below" cue for the foot of a hero section. Two
* glyphs, both pure CSS loops: a mouse shell with a dot that falls and fades,
* or a pair of chevrons pulsing downward out of step with each other.
* `motion-reduce:` freezes the loop and leaves the glyph sitting still.
*/
export function ScrollHint({
variant = "mouse",
className,
label,
}: ScrollHintProps) {
return (
<>
<style>{KEYFRAMES}</style>
<div
className={cn(
"inline-flex flex-col items-center gap-2 text-foreground",
className,
)}
>
{variant === "mouse" && <MouseGlyph />}
{variant === "chevron" && <ChevronGlyph />}
{label && (
<span className="text-xs text-muted-foreground">{label}</span>
)}
</div>
</>
);
}
const KEYFRAMES = `
@keyframes uilab-scroll-hint-dot {
0% { transform: translateY(0); opacity: 0; }
20% { opacity: 1; }
60% { opacity: 1; }
100% { transform: translateY(10px); opacity: 0; }
}
@keyframes uilab-scroll-hint-chevron {
0%, 100% { opacity: 0.3; transform: translateY(-2px); }
50% { opacity: 1; transform: translateY(2px); }
}
`;
function MouseGlyph() {
return (
<svg
aria-hidden="true"
width="20"
height="30"
viewBox="0 0 20 30"
fill="none"
>
<rect
x="1.25"
y="1.25"
width="17.5"
height="27.5"
rx="8.75"
stroke="currentColor"
strokeWidth="1.5"
/>
<circle
cx="10"
cy="8.5"
r="2"
fill="currentColor"
className="animate-[uilab-scroll-hint-dot_1.8s_infinite] motion-reduce:animate-none"
style={{ animationTimingFunction: EASE_OUT_CSS }}
/>
</svg>
);
}
function ChevronGlyph() {
return (
<svg
aria-hidden="true"
width="24"
height="28"
viewBox="0 0 24 28"
fill="none"
>
<path
d="M6 6L12 12L18 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="animate-[uilab-scroll-hint-chevron_1.6s_infinite] motion-reduce:animate-none"
style={{ animationTimingFunction: EASE_OUT_CSS }}
/>
<path
d="M6 15L12 21L18 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="animate-[uilab-scroll-hint-chevron_1.6s_infinite] motion-reduce:animate-none"
style={{ animationTimingFunction: EASE_OUT_CSS, animationDelay: "0.35s" }}
/>
</svg>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/scroll-hint
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx 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/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/scroll-hint.tsx
// ui-lab-ten.vercel.app/components/motion/scroll-hint
import { EASE_OUT_CSS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ScrollHintVariant = "mouse" | "chevron";
export interface ScrollHintProps {
/** Which glyph to render. */
variant?: ScrollHintVariant;
className?: string;
/** Optional caption under the glyph, e.g. "Scroll". */
label?: string;
}
/**
* A quiet "there's more below" cue for the foot of a hero section. Two
* glyphs, both pure CSS loops: a mouse shell with a dot that falls and fades,
* or a pair of chevrons pulsing downward out of step with each other.
* `motion-reduce:` freezes the loop and leaves the glyph sitting still.
*/
export function ScrollHint({
variant = "mouse",
className,
label,
}: ScrollHintProps) {
return (
<>
<style>{KEYFRAMES}</style>
<div
className={cn(
"inline-flex flex-col items-center gap-2 text-foreground",
className,
)}
>
{variant === "mouse" && <MouseGlyph />}
{variant === "chevron" && <ChevronGlyph />}
{label && (
<span className="text-xs text-muted-foreground">{label}</span>
)}
</div>
</>
);
}
const KEYFRAMES = `
@keyframes uilab-scroll-hint-dot {
0% { transform: translateY(0); opacity: 0; }
20% { opacity: 1; }
60% { opacity: 1; }
100% { transform: translateY(10px); opacity: 0; }
}
@keyframes uilab-scroll-hint-chevron {
0%, 100% { opacity: 0.3; transform: translateY(-2px); }
50% { opacity: 1; transform: translateY(2px); }
}
`;
function MouseGlyph() {
return (
<svg
aria-hidden="true"
width="20"
height="30"
viewBox="0 0 20 30"
fill="none"
>
<rect
x="1.25"
y="1.25"
width="17.5"
height="27.5"
rx="8.75"
stroke="currentColor"
strokeWidth="1.5"
/>
<circle
cx="10"
cy="8.5"
r="2"
fill="currentColor"
className="animate-[uilab-scroll-hint-dot_1.8s_infinite] motion-reduce:animate-none"
style={{ animationTimingFunction: EASE_OUT_CSS }}
/>
</svg>
);
}
function ChevronGlyph() {
return (
<svg
aria-hidden="true"
width="24"
height="28"
viewBox="0 0 24 28"
fill="none"
>
<path
d="M6 6L12 12L18 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="animate-[uilab-scroll-hint-chevron_1.6s_infinite] motion-reduce:animate-none"
style={{ animationTimingFunction: EASE_OUT_CSS }}
/>
<path
d="M6 15L12 21L18 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="animate-[uilab-scroll-hint-chevron_1.6s_infinite] motion-reduce:animate-none"
style={{ animationTimingFunction: EASE_OUT_CSS, animationDelay: "0.35s" }}
/>
</svg>
);
}
API 参考
variant?"mouse" | "chevron"Which glyph to render.
mouseclassName?string—label?stringOptional caption under the glyph, e.g. "Scroll".
—Keep 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.