Skip to content

Commit d54b60a

Browse files
authored
feat: add reveal text on scroll component (codse#187)
1 parent 9ef9862 commit d54b60a

File tree

5 files changed

+210
-1
lines changed

5 files changed

+210
-1
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Baby, File } from "lucide-react";
2+
3+
import ScrollReveal from "@/animata/text/scroll-reveal";
4+
import { Meta, StoryObj } from "@storybook/react";
5+
6+
const meta = {
7+
title: "Text/Scroll Reveal",
8+
component: ScrollReveal,
9+
parameters: {
10+
layout: "centered",
11+
},
12+
tags: ["autodocs"],
13+
argTypes: {},
14+
} satisfies Meta<typeof ScrollReveal>;
15+
16+
export default meta;
17+
type Story = StoryObj<typeof meta>;
18+
19+
export const Primary: Story = {
20+
args: {
21+
className: "md:text-2xl",
22+
children: (
23+
<>
24+
This component reveals its children{" "}
25+
<Baby className="scroll-baby size-5 transition-all duration-75 ease-in-out md:size-8" /> as
26+
you scroll down the page{" "}
27+
<File className="scroll-file size-5 transition-all duration-75 ease-in-out md:size-8" />
28+
.
29+
<div className="my-4 w-full" />
30+
It uses a sticky container with a fixed height and a large space at the bottom. Finally, it
31+
calculates the scroll position and applies an opacity effect to each child based on its
32+
position.
33+
<div className="mt-4 w-full">Node with children.</div>
34+
</>
35+
),
36+
},
37+
};
38+
39+
export const TextOnly: Story = {
40+
args: {
41+
className: "md:text-3xl text-blue-200 dark:text-blue-800",
42+
children:
43+
"It uses a sticky container with a fixed height and a large space at the bottom. Finally, it calculates the scroll position and applies an opacity effect to each child based on its position.",
44+
},
45+
};

animata/text/scroll-reveal.tsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import React, { useRef } from "react";
2+
import { motion, MotionValue, useScroll, useTransform } from "framer-motion";
3+
4+
import { cn } from "@/lib/utils";
5+
6+
interface ScrollRevealProps
7+
extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
8+
children: React.ReactNode;
9+
className?: string;
10+
}
11+
12+
// This function might need updates to support different cases.
13+
const flatten = (children: React.ReactNode): React.ReactNode[] => {
14+
const result: React.ReactNode[] = [];
15+
16+
React.Children.forEach(children, (child) => {
17+
if (React.isValidElement(child)) {
18+
if (child.type === React.Fragment) {
19+
result.push(...flatten(child.props.children));
20+
} else if (child.props.children) {
21+
result.push(React.cloneElement(child, {}));
22+
} else {
23+
result.push(child);
24+
}
25+
} else {
26+
const parts = String(child).split(/(\s+)/);
27+
result.push(
28+
...parts.map((part, index) => <React.Fragment key={index}>{part}</React.Fragment>),
29+
);
30+
}
31+
});
32+
33+
return result.flatMap((child) => (Array.isArray(child) ? child : [child]));
34+
};
35+
36+
function OpacityChild({
37+
children,
38+
index,
39+
progress,
40+
total,
41+
}: {
42+
children: React.ReactNode;
43+
index: number;
44+
total: number;
45+
progress: MotionValue<number>;
46+
}) {
47+
const opacity = useTransform(progress, [index / total, (index + 1) / total], [0.5, 1]);
48+
49+
let className = "";
50+
if (React.isValidElement(children)) {
51+
className = Reflect.get(children, "props")?.className;
52+
}
53+
54+
return (
55+
<motion.span style={{ opacity }} className={cn(className, "h-fit")}>
56+
{children}
57+
</motion.span>
58+
);
59+
}
60+
61+
export default function ScrollReveal({ children, className, ...props }: ScrollRevealProps) {
62+
const flat = flatten(children);
63+
const count = flat.length;
64+
const containerRef = useRef<HTMLDivElement>(null);
65+
66+
const { scrollYProgress } = useScroll({
67+
container: containerRef,
68+
});
69+
70+
return (
71+
<div
72+
{...props}
73+
ref={containerRef}
74+
className={cn(
75+
// Adjust the height and spacing according to the need
76+
"storybook-fix relative h-96 w-full overflow-y-scroll bg-foreground text-background dark:text-zinc-900",
77+
className,
78+
)}
79+
>
80+
<div className="sticky top-0 flex h-full w-full items-center justify-center">
81+
<div className="flex h-fit w-full min-w-fit flex-wrap whitespace-break-spaces p-8">
82+
{flat.map((child, index) => {
83+
return (
84+
<OpacityChild
85+
progress={scrollYProgress}
86+
index={index}
87+
total={flat.length}
88+
key={index}
89+
>
90+
{child}
91+
</OpacityChild>
92+
);
93+
})}
94+
</div>
95+
</div>
96+
{Array.from({ length: count }).map((_, index) => (
97+
// Create really large area to make the scroll effect work
98+
<div key={index} className="h-32" />
99+
))}
100+
</div>
101+
);
102+
}

components/mdx-base-components.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ export const baseComponents = {
4444
/>
4545
),
4646
a: ({ className, ...props }: React.HTMLAttributes<HTMLAnchorElement>) => (
47-
<a className={cn("font-medium underline underline-offset-4", className)} {...props} />
47+
<a
48+
className={cn("font-medium underline underline-offset-4", className)}
49+
{...props}
50+
target={Reflect.get(props, "href")?.toString().startsWith("http") ? "_blank" : undefined}
51+
/>
4852
),
4953
p: ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => (
5054
<p className={cn("leading-7 [&:not(:first-child)]:mt-6", className)} {...props} />

content/docs/text/scroll-reveal.mdx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
title: Scroll Reveal
3+
description: A component that reveals text based on the scroll position. Make sure to scroll inside the preview to see the effect.
4+
author: harimanok_
5+
labels: ["requires interaction", "scroll"]
6+
---
7+
8+
<ComponentPreview name="text-scroll-reveal--docs" />
9+
10+
## Installation
11+
12+
<Steps>
13+
14+
<Step>(optional): Update globals.css</Step>
15+
16+
This is just for changing the color of the icon once it is revealed. You can skip this step if you don't want to change the color.
17+
18+
```css
19+
.scroll-baby[style*="opacity: 1"] {
20+
@apply text-yellow-300 dark:text-yellow-500;
21+
}
22+
23+
.scroll-file[style*="opacity: 1"] {
24+
@apply text-blue-300 dark:text-blue-500;
25+
}
26+
```
27+
28+
<Step>Run the following command</Step>
29+
30+
It will create a new file `scroll-reveal.tsx` inside the `components/animata/text` directory.
31+
32+
```bash
33+
mkdir -p components/animata/text && touch components/animata/text/scroll-reveal.tsx
34+
```
35+
36+
<Step>Paste the code</Step>{" "}
37+
38+
Open the newly created file and paste the following code:
39+
40+
```jsx file=<rootDir>/animata/text/scroll-reveal.tsx
41+
42+
```
43+
44+
</Steps>
45+
46+
## Credits
47+
48+
Built by [hari](https://github.com/hari)
49+
50+
Inspired by: [onassemble](https://onassemble.com/)

styles/globals.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,11 @@
114114
initial-value: 0deg;
115115
inherits: false;
116116
}
117+
118+
.scroll-baby[style*="opacity: 1"] {
119+
@apply text-yellow-300 dark:text-yellow-500;
120+
}
121+
122+
.scroll-file[style*="opacity: 1"] {
123+
@apply text-blue-300 dark:text-blue-500;
124+
}

0 commit comments

Comments
 (0)