A React section that shows a peek of your skills with animated images. Built for portfolios and landing pages.
import SkillPeek, { type SkillPeekItem } from "@components/skill-peek";
const items: SkillPeekItem[] = [
{ text: "Branding", imgSrc: "/assets/skill-peek/branding.jpg" },
{ text: "Art Direction", imgSrc: "/assets/skill-peek/art_direction.jpg" },
{ text: "Design", imgSrc: "/assets/skill-peek/design.jpg" },
{ text: "Website Build", imgSrc: "/assets/skill-peek/website_build.jpg" },
];
function SkillPeekDemo() {
return (
<section className="h-svh" aria-label="Skills">
<SkillPeek
items={items}
className="size-full"
/>
</section>
);
}
Install the following dependencies:
npm install motionbun add motiondeno add motionpnpm add motionyarn add motionCopy and paste the following code into your project.
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
useSpring,
} from "motion/react";
import { useCallback, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const Spring = {
damping: 10,
mass: 0.1,
stiffness: 100,
type: "spring" as const,
};
const PreviewTransition = { duration: 0.3, type: "spring" as const };
const ImageTransition = { bounce: 0, duration: 0.4, type: "spring" as const };
/** 1 = higher index, -1 = lower index */
type Direction = 1 | -1;
function getEnterY(direction: Direction | 0): string {
if (direction === 0) {
return "0%";
}
if (direction > 0) {
return "-100%";
}
return "100%";
}
function getExitY(direction: Direction | 0): string {
if (direction === 0) {
return "0%";
}
if (direction > 0) {
return "100%";
}
return "-100%";
}
const imageVariants = {
center: {
y: "0%",
},
enter: (direction: Direction | 0) => ({
y: getEnterY(direction),
}),
exit: (direction: Direction | 0) => ({
y: getExitY(direction),
}),
};
export interface SkillPeekItem {
ariaLabel?: string;
imgAlt?: string;
imgClassName?: string;
imgSrc: string;
text: string;
textClassName?: string;
}
export interface SkillPeekProps {
/** Accessible name for the list. */
"aria-label"?: string;
className?: string;
items: SkillPeekItem[];
/** Class names for the floating preview frame. */
previewClassName?: string;
}
export default function SkillPeek({
items,
className,
previewClassName,
"aria-label": ariaLabel = "Services",
}: SkillPeekProps) {
const listId = useId();
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const activeRef = useRef<number | null>(null);
const directionRef = useRef<Direction | 0>(0);
const shouldReduceMotion = useReducedMotion();
const x = useSpring(0, Spring);
const y = useSpring(0, Spring);
const activate = useCallback((index: number) => {
if (activeRef.current === index) {
return;
}
const prev = activeRef.current;
if (prev === null) {
directionRef.current = 0;
} else if (index > prev) {
directionRef.current = 1;
} else {
directionRef.current = -1;
}
activeRef.current = index;
setActiveIndex(index);
}, []);
const clear = useCallback(() => {
activeRef.current = null;
directionRef.current = 0;
setActiveIndex(null);
}, []);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
x.set(e.clientX + 12);
y.set(e.clientY + 12);
},
[x, y]
);
const handleListBlur = useCallback(
(e: React.FocusEvent<HTMLUListElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
clear();
}
},
[clear]
);
const handleListFocus = useCallback(
(e: React.FocusEvent<HTMLUListElement>) => {
const button = (e.target as HTMLElement).closest<HTMLButtonElement>(
"button[data-index]"
);
if (button?.dataset.index !== undefined) {
activate(Number(button.dataset.index));
}
},
[activate]
);
const handleListMouseOver = useCallback(
(e: React.MouseEvent<HTMLUListElement>) => {
const button = (e.target as HTMLElement).closest<HTMLButtonElement>(
"button[data-index]"
);
if (button?.dataset.index !== undefined) {
activate(Number(button.dataset.index));
}
},
[activate]
);
const direction = directionRef.current;
const activeItem = activeIndex === null ? undefined : items[activeIndex];
return (
<div
className={cn(
"relative flex flex-col items-center justify-center",
className
)}
onPointerMove={handlePointerMove}
>
<ul
aria-label={ariaLabel}
className="flex flex-col items-center"
onBlur={handleListBlur}
onFocus={handleListFocus}
onMouseOver={handleListMouseOver}
onPointerLeave={clear}
>
{items.map((item, index) => {
const isActive = activeIndex === index;
return (
<li className="w-full list-none" key={item.text}>
<button
aria-describedby={isActive ? `${listId}-preview` : undefined}
aria-label={item.ariaLabel ?? item.text}
className={cn(
"w-full cursor-pointer bg-transparent py-4 text-center font-extralight text-8xl uppercase transition-colors duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#101010] focus-visible:ring-offset-4",
"text-[#b1b3b3] data-[active=true]:text-[#101010]",
item.textClassName
)}
data-active={isActive}
data-index={index}
type="button"
>
{item.text}
</button>
</li>
);
})}
</ul>
<AnimatePresence>
{activeIndex !== null && activeItem ? (
<motion.div
animate={{ opacity: 1, scale: 1 }}
aria-label={activeItem.text}
className={cn(
"pointer-events-none fixed top-0 left-0 h-50 w-70 overflow-hidden rounded-xs bg-white",
previewClassName
)}
exit={{ opacity: 0, scale: 0.95 }}
id={`${listId}-preview`}
initial={{ opacity: 0, scale: 0.95 }}
key="preview"
role="img"
style={{ x, y }}
transition={
shouldReduceMotion ? { duration: 0 } : PreviewTransition
}
>
<AnimatePresence custom={direction}>
{/* biome-ignore lint/performance/noImgElement: motion image variants require native img */}
<motion.img
alt=""
animate="center"
aria-hidden="true"
className={cn(
"absolute inset-0 h-full w-full object-cover",
activeItem.imgClassName
)}
custom={direction}
exit="exit"
height={200}
initial="enter"
key={activeIndex}
src={activeItem.imgSrc}
transition={
shouldReduceMotion ? { duration: 0 } : ImageTransition
}
variants={imageVariants}
width={280}
/>
</AnimatePresence>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}With motion reduced, images are switched instantly.