Skip to content

Commit 910781f

Browse files
authored
feat: create image trail component (codse#156)
1 parent 6086be9 commit 910781f

File tree

7 files changed

+268
-3
lines changed

7 files changed

+268
-3
lines changed

animata/card/github-card-shiny.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export default function GithubCardShiny() {
3030
<div
3131
ref={overlayRef}
3232
// Adjust height & width as required
33-
className="-z-1 absolute h-64 w-64 rounded-full bg-white opacity-0 bg-blend-soft-light blur-3xl transition-opacity group-hover:opacity-10"
33+
className="-z-1 absolute h-64 w-64 rounded-full bg-white opacity-0 bg-blend-soft-light blur-3xl transition-opacity group-hover:opacity-20"
3434
style={{
3535
transform: "translate(var(--x), var(--y))",
3636
}}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import TrailingImage from "@/animata/image/trailing-image";
2+
import { Meta, StoryObj } from "@storybook/react";
3+
4+
const meta = {
5+
title: "Image/Trailing Image",
6+
component: TrailingImage,
7+
parameters: {
8+
layout: "centered",
9+
},
10+
tags: ["autodocs"],
11+
argTypes: {},
12+
} satisfies Meta<typeof TrailingImage>;
13+
14+
export default meta;
15+
type Story = StoryObj<typeof meta>;
16+
17+
export const Primary: Story = {
18+
args: {},
19+
};

animata/image/trailing-image.tsx

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import React, { createRef, forwardRef, useCallback, useImperativeHandle, useRef } from "react";
2+
import { motion, useAnimation } from "framer-motion";
3+
4+
import { useMousePosition } from "@/hooks/use-mouse-position";
5+
import { getDistance, lerp } from "@/lib/utils";
6+
7+
interface AnimatedImageRef {
8+
show: ({
9+
x,
10+
y,
11+
newX,
12+
newY,
13+
zIndex,
14+
}: {
15+
x: number;
16+
y: number;
17+
zIndex: number;
18+
newX: number;
19+
newY: number;
20+
}) => void;
21+
isActive: () => boolean;
22+
}
23+
24+
const AnimatedImage = forwardRef<AnimatedImageRef, { src: string }>(({ src }, ref) => {
25+
const controls = useAnimation();
26+
const isRunning = useRef(false);
27+
const imgRef = useRef<HTMLImageElement>(null);
28+
29+
useImperativeHandle(ref, () => ({
30+
isActive: () => isRunning.current,
31+
show: async ({
32+
x,
33+
y,
34+
newX,
35+
newY,
36+
zIndex,
37+
}: {
38+
x: number;
39+
y: number;
40+
zIndex: number;
41+
newX: number;
42+
newY: number;
43+
}) => {
44+
const rect = imgRef.current?.getBoundingClientRect();
45+
if (!rect) {
46+
return;
47+
}
48+
49+
const center = (posX: number, posY: number) => {
50+
const coords = {
51+
x: posX - rect.width / 2,
52+
y: posY - rect.height / 2,
53+
};
54+
return `translate(${coords.x}px, ${coords.y}px)`;
55+
};
56+
57+
controls.stop();
58+
59+
controls.set({
60+
opacity: isRunning.current ? 1 : 0.75,
61+
zIndex,
62+
transform: `${center(x, y)} scale(1)`,
63+
transition: { ease: "circOut" },
64+
});
65+
66+
isRunning.current = true;
67+
68+
await controls.start({
69+
opacity: 1,
70+
transform: `${center(newX, newY)} scale(1)`,
71+
transition: { duration: 0.9, ease: "circOut" },
72+
});
73+
74+
await Promise.all([
75+
controls.start({
76+
transition: { duration: 1, ease: "easeInOut" },
77+
transform: `${center(newX, newY)} scale(0.1)`,
78+
}),
79+
controls.start({
80+
opacity: 0,
81+
transition: { duration: 1.1, ease: "easeOut" },
82+
}),
83+
]);
84+
85+
isRunning.current = false;
86+
},
87+
}));
88+
89+
return (
90+
<motion.img
91+
ref={imgRef}
92+
initial={{ opacity: 0, scale: 1 }}
93+
animate={controls}
94+
src={src}
95+
alt="trail element"
96+
className="absolute h-56 w-44 object-cover"
97+
/>
98+
);
99+
});
100+
101+
AnimatedImage.displayName = "AnimatedImage";
102+
103+
const images = [
104+
"https://assets.lummi.ai/assets/Qma1aBRXFsApFohRJrpJczE5QXGY6HhHKz24ybuw1khbou?auto=format&w=500",
105+
"https://assets.lummi.ai/assets/QmZBpAeh18DHxVNEEcJErt1UXGjZYCedSidJ6cybrDZdeS?auto=format&w=500",
106+
"https://assets.lummi.ai/assets/QmbMZFEfk2qwQkkmXYncpvHapkNQF5HuTrcascJC7edpfW?auto=format&w=500",
107+
"https://assets.lummi.ai/assets/QmXm6HVi3wwGy3jaCmECfoL8AULPerjQQh6abKTVhFMewK?auto=format&w=500",
108+
"https://assets.lummi.ai/assets/QmRy3tpFDCbgA3CQgRpySTGN6tNdomQE96rMpV31HeBUUd?auto=format&w=500",
109+
];
110+
111+
const TrailingImage = () => {
112+
const containerRef = useRef<HTMLDivElement>(null);
113+
// Create a maximum of 20 trails for a smoother experience
114+
const trailsRef = useRef(
115+
Array.from({ length: Math.max(20, images.length) }, createRef<AnimatedImageRef>),
116+
);
117+
118+
const lastPosition = useRef({ x: 0, y: 0 });
119+
const cachedPosition = useRef({ x: 0, y: 0 });
120+
const imageIndex = useRef(0);
121+
const zIndex = useRef(1);
122+
123+
const update = useCallback((cursor: { x: number; y: number }) => {
124+
const activeRefCount = trailsRef.current.filter((ref) => ref.current?.isActive()).length;
125+
if (activeRefCount === 0) {
126+
// Reset zIndex since there are no active references
127+
// This prevents zIndex from incrementing indefinitely
128+
zIndex.current = 1;
129+
}
130+
131+
const distance = getDistance(
132+
cursor.x,
133+
cursor.y,
134+
lastPosition.current.x,
135+
lastPosition.current.y,
136+
);
137+
const threshold = 50;
138+
139+
const newCachePosition = {
140+
x: lerp(cachedPosition.current.x || cursor.x, cursor.x, 0.1),
141+
y: lerp(cachedPosition.current.y || cursor.y, cursor.y, 0.1),
142+
};
143+
cachedPosition.current = newCachePosition;
144+
145+
if (distance > threshold) {
146+
imageIndex.current = (imageIndex.current + 1) % trailsRef.current.length;
147+
zIndex.current = zIndex.current + 1;
148+
lastPosition.current = cursor;
149+
trailsRef.current[imageIndex.current].current?.show?.({
150+
x: newCachePosition.x,
151+
y: newCachePosition.y,
152+
zIndex: zIndex.current,
153+
newX: cursor.x,
154+
newY: cursor.y,
155+
});
156+
}
157+
}, []);
158+
159+
useMousePosition(containerRef, update);
160+
161+
return (
162+
<div ref={containerRef} className="storybook-fix relative flex min-h-96 w-full">
163+
{trailsRef.current.map((ref, index) => (
164+
<AnimatedImage key={index} ref={ref} src={images[index % images.length]} />
165+
))}
166+
<div className="flex w-full flex-1 items-center justify-center p-4 text-center text-sm text-foreground md:text-3xl">
167+
<div className="max-w-sm">Move your mouse over this element to see the effect</div>
168+
</div>
169+
</div>
170+
);
171+
};
172+
173+
export default TrailingImage;

animata/widget/notes.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export function NotesCard({ title, children }: { title?: string; children?: React.ReactNode }) {
22
return (
3-
<div className="h-64 w-48 rounded-3xl border bg-[#fced99] p-4 font-sans shadow-sm">
3+
<div className="h-64 w-48 rounded-3xl border bg-[#fced99] p-4 font-sans text-zinc-950 shadow-sm">
44
<div className="text-lg font-bold tracking-wide">{title}</div>
55
<div className="mt-3 flex flex-col gap-3 text-sm">{children}</div>
66
</div>

animata/widget/shopping-list.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ export default function ShoppingList({
6060
}) {
6161
return (
6262
<div className="h-64 w-48 rounded-3xl border bg-white p-4 font-sans shadow-sm">
63-
<div className="text-lg font-bold tracking-wide">{title || "Shopping list"}</div>
63+
<div className="text-lg font-bold tracking-wide text-zinc-950">
64+
{title || "Shopping list"}
65+
</div>
6466
<div className="mt-4 flex flex-col gap-4 text-sm">
6567
{data.map((item, index) => (
6668
<Checkbox key={index} title={item.title} checked={item.checked} />

content/docs/image/trailing-image.mdx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
title: Trailing Image
3+
description: A trailing effect where the images move with the mouse.
4+
author: harimanok_
5+
labels: ["requires interaction", "hover"]
6+
---
7+
8+
<ComponentPreview name="image-trailing-image--docs" />
9+
10+
## Installation
11+
12+
<Steps>
13+
<Step>Install dependencies</Step>
14+
15+
```bash
16+
npm install framer-motion
17+
```
18+
19+
<Step>Copy the `useMousePosition` hook</Step>
20+
21+
```jsx file=<rootDir>/hooks/use-mouse-position.ts
22+
23+
```
24+
25+
<Step>Copy the helper functions (`lerp` and `getDistance`) to `lib/utils.ts`</Step>
26+
27+
```ts
28+
// Linear interpolation
29+
export function lerp(a: number, b: number, n: number) {
30+
return (1 - n) * a + n * b;
31+
}
32+
33+
// Get distance between two points
34+
export function getDistance(x1: number, y1: number, x2: number, y2: number) {
35+
return Math.hypot(x2 - x1, y2 - y1);
36+
}
37+
```
38+
39+
<Step>Run the following command</Step>
40+
41+
It will create a new file `trailing-image.tsx` inside the `components/animata/image` directory.
42+
43+
```bash
44+
mkdir -p components/animata/image && touch components/animata/image/trailing-image.tsx
45+
```
46+
47+
<Step>Paste the code</Step>{" "}
48+
49+
Open the newly created file and paste the following code:
50+
51+
```jsx file=<rootDir>/animata/image/trailing-image.tsx
52+
53+
```
54+
55+
</Steps>
56+
57+
## Credits
58+
59+
Built by [hari](https://github.com/hari)
60+
61+
Images from [lummi](https://lummi.ai/)

lib/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,13 @@ export function cn(...inputs: ClassValue[]) {
88
export function absoluteUrl(path: string) {
99
return `${process.env.NEXT_PUBLIC_APP_URL}${path}`;
1010
}
11+
12+
// Linear interpolation
13+
export function lerp(a: number, b: number, n: number) {
14+
return (1 - n) * a + n * b;
15+
}
16+
17+
// Get distance between two points
18+
export function getDistance(x1: number, y1: number, x2: number, y2: number) {
19+
return Math.hypot(x2 - x1, y2 - y1);
20+
}

0 commit comments

Comments
 (0)