共享布局背景块
借助 Framer Motion 的共享布局,让一枚胶囊背景块在悬停项之间平滑滑动,进出场带模糊过渡。
TSXcomponents/previews/motion/shared-layout-bg.preview.tsx
"use client";
import { ArrowUpRight } from "lucide-react";
import { SharedLayoutBg } from "@/components/motion/shared-layout-bg";
const items = [
{ title: "Inbox", body: "12 unread threads, 3 mentions today." },
{ title: "Drafts", body: "4 posts waiting for a final pass." },
{ title: "Releases", body: "Last shipped 2 days ago, v0.4.1." },
{ title: "Billing", body: "Plan renews on the 1st of next month." },
];
export function SharedLayoutBgPreview() {
return (
<div className="w-full max-w-lg px-2">
<SharedLayoutBg>
{items.map((it) => (
<button
type="button"
key={it.title}
className="group flex flex-col gap-1 px-2 py-3 text-left"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-foreground">{it.title}</span>
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</div>
<p className="text-sm text-muted-foreground">{it.body}</p>
</button>
))}
</SharedLayoutBg>
</div>
);
}
TSXcomponents/motion/shared-layout-bg.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/shared-layout-bg
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
Children,
cloneElement,
isValidElement,
useId,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface SharedLayoutBgProps {
children: ReactNode;
className?: string;
/** Tailwind class applied to the moving pill. Defaults to a subtle foreground tint. */
pillClassName?: string;
/** Horizontal inset of the pill relative to each row (px). Default 20. */
inset?: number;
}
const variants: Variants = {
initial: { opacity: 0, filter: "blur(6px)" },
animate: { opacity: 1, filter: "blur(0px)" },
exit: (isActive: boolean) =>
!isActive ? { opacity: 0, filter: "blur(6px)" } : {},
};
const reducedVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: (isActive: boolean) => (!isActive ? { opacity: 0 } : {}),
};
export function SharedLayoutBg({
children,
className,
pillClassName,
inset = 20,
}: SharedLayoutBgProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const uid = useId();
const reduce = useReducedMotion();
return (
// layoutRoot scopes the pill's layout projection to this list, so fixed or
// scrolled ancestors can't smear scroll offsets into its movement.
<motion.div
layoutRoot
onMouseLeave={() => setActiveId(null)}
className={cn("flex w-full flex-col", className)}
>
{Children.toArray(children)
.filter(isValidElement)
.map((child, index) => {
const el = child as ReactElement<{ className?: string; onMouseEnter?: () => void; children?: ReactNode }>;
const childKey = el.key ? String(el.key) : `item-${index}`;
return cloneElement(
el,
{
key: childKey,
className: cn("relative", el.props.className),
onMouseEnter: () => setActiveId(childKey),
},
<>
<AnimatePresence custom={activeId !== null}>
{activeId !== null ? (
<motion.div
variants={reduce ? reducedVariants : variants}
initial="initial"
animate="animate"
exit="exit"
custom={activeId !== null}
className="pointer-events-none absolute inset-y-0"
style={{ left: -inset, right: -inset }}
>
{activeId === childKey ? (
<motion.div
layoutId={`shared-bg-${uid}`}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"pointer-events-none h-full w-full rounded-2xl bg-primary/[0.06]",
pillClassName,
)}
/>
) : null}
</motion.div>
) : null}
</AnimatePresence>
<div className="relative z-10">{el.props.children}</div>
</>
);
})}
</motion.div>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/shared-layout-bg
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react 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/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/shared-layout-bg.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/shared-layout-bg
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
Children,
cloneElement,
isValidElement,
useId,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface SharedLayoutBgProps {
children: ReactNode;
className?: string;
/** Tailwind class applied to the moving pill. Defaults to a subtle foreground tint. */
pillClassName?: string;
/** Horizontal inset of the pill relative to each row (px). Default 20. */
inset?: number;
}
const variants: Variants = {
initial: { opacity: 0, filter: "blur(6px)" },
animate: { opacity: 1, filter: "blur(0px)" },
exit: (isActive: boolean) =>
!isActive ? { opacity: 0, filter: "blur(6px)" } : {},
};
const reducedVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: (isActive: boolean) => (!isActive ? { opacity: 0 } : {}),
};
export function SharedLayoutBg({
children,
className,
pillClassName,
inset = 20,
}: SharedLayoutBgProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const uid = useId();
const reduce = useReducedMotion();
return (
// layoutRoot scopes the pill's layout projection to this list, so fixed or
// scrolled ancestors can't smear scroll offsets into its movement.
<motion.div
layoutRoot
onMouseLeave={() => setActiveId(null)}
className={cn("flex w-full flex-col", className)}
>
{Children.toArray(children)
.filter(isValidElement)
.map((child, index) => {
const el = child as ReactElement<{ className?: string; onMouseEnter?: () => void; children?: ReactNode }>;
const childKey = el.key ? String(el.key) : `item-${index}`;
return cloneElement(
el,
{
key: childKey,
className: cn("relative", el.props.className),
onMouseEnter: () => setActiveId(childKey),
},
<>
<AnimatePresence custom={activeId !== null}>
{activeId !== null ? (
<motion.div
variants={reduce ? reducedVariants : variants}
initial="initial"
animate="animate"
exit="exit"
custom={activeId !== null}
className="pointer-events-none absolute inset-y-0"
style={{ left: -inset, right: -inset }}
>
{activeId === childKey ? (
<motion.div
layoutId={`shared-bg-${uid}`}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"pointer-events-none h-full w-full rounded-2xl bg-primary/[0.06]",
pillClassName,
)}
/>
) : null}
</motion.div>
) : null}
</AnimatePresence>
<div className="relative z-10">{el.props.children}</div>
</>
);
})}
</motion.div>
);
}
API 参考
className?string—pillClassName?stringTailwind class applied to the moving pill. Defaults to a subtle foreground tint.
—inset?numberHorizontal inset of the pill relative to each row (px). Default 20.
20Keep 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.