@matteodicristofalo/text-animations 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ import type { StorybookConfig } from "@storybook/react-vite";
2
+ import tsconfigPaths from "vite-tsconfig-paths";
3
+
4
+ const config: StorybookConfig = {
5
+ stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
6
+ addons: [
7
+ "@storybook/addon-onboarding",
8
+ "@storybook/addon-essentials",
9
+ "@chromatic-com/storybook",
10
+ "@storybook/addon-interactions",
11
+ ],
12
+ framework: {
13
+ name: "@storybook/react-vite",
14
+ options: {},
15
+ },
16
+ viteFinal: async (config) => {
17
+ config.plugins?.push(tsconfigPaths());
18
+ return config;
19
+ },
20
+ };
21
+
22
+ export default config;
@@ -0,0 +1,14 @@
1
+ import type { Preview } from "@storybook/react";
2
+
3
+ const preview: Preview = {
4
+ parameters: {
5
+ controls: {
6
+ matchers: {
7
+ color: /(background|color)$/i,
8
+ date: /Date$/i,
9
+ },
10
+ },
11
+ },
12
+ };
13
+
14
+ export default preview;
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # @mdc/text-animations
2
+
3
+ This library provides a set of React components to animate text
4
+
5
+ # Installation
6
+
7
+ ```bash
8
+ npm install @mdc/text-animations
9
+ # or
10
+ yarn add @mdc/text-animations
11
+ # or
12
+ pnpm add @mdc/text-animations
13
+ ```
14
+
15
+ # Usage
16
+
17
+ The first thing to do in order to use this library is to import it's CSS.
18
+
19
+ The following example is for a Next.js App but you can apply the same concept for different frameworks
20
+
21
+ ```js
22
+ // app/layout.tsx
23
+
24
+ ...
25
+ import "@mdc/text-animations/index.css";
26
+
27
+
28
+ export default function RootLayout({
29
+ children,
30
+ }: Readonly<{
31
+ children: React.ReactNode;
32
+ }>) {
33
+ return (
34
+ <html lang="en">
35
+ <body>
36
+ {children}
37
+ </body>
38
+ </html>
39
+ );
40
+ }
41
+ ```
42
+
43
+ Once imported the CSS you can use the React components.
44
+
45
+ ## SplitTextRevel
46
+
47
+ ```js
48
+ "use client";
49
+
50
+ import { SplitTextRevel } from "@mdc/text-animations";
51
+
52
+ export function MyComponent() {
53
+ return (
54
+ <h1>
55
+ <SplitTextRevel text="Hello World" />
56
+ </h1>
57
+ );
58
+ }
59
+ ```
60
+
61
+ You can also choose how to split the text between one of this options:
62
+
63
+ - char
64
+ - word
65
+ - sentence
66
+
67
+ ```js
68
+ <SplitTextRevel text="Hello World" splitType="word" />
69
+ ```
70
+
71
+ You can also configure the reveal animation by specifying:
72
+
73
+ - duration
74
+ - stagger
75
+
76
+ ```js
77
+ "use client";
78
+
79
+ import { useMemo } from "react";
80
+ import { SplitTextRevel } from "@mdc/text-animations";
81
+
82
+ export function MyComponent() {
83
+ const revealOptions = useMemo(
84
+ () => ({
85
+ duration: 1,
86
+ stagger: 0.5,
87
+ }),
88
+ []
89
+ );
90
+
91
+ return (
92
+ <h1>
93
+ <SplitTextRevel text="Hello World" revealOptions={revealOptions} />
94
+ </h1>
95
+ );
96
+ }
97
+ ```
98
+
99
+ ## SplitTextRotate
100
+
101
+ ```js
102
+ "use client";
103
+
104
+ import { SplitTextRotate } from "@mdc/text-animations";
105
+
106
+ export function MyComponent() {
107
+ return (
108
+ <h1>
109
+ <SplitTextRotate text="Hello World" />
110
+ </h1>
111
+ );
112
+ }
113
+ ```
114
+
115
+ Also here you can configure the aniimation
116
+
117
+ ```js
118
+ "use client";
119
+
120
+ import { useMemo } from "react";
121
+ import { SplitTextRotate } from "@mdc/text-animations";
122
+
123
+ export function MyComponent() {
124
+ const rotateOptions = useMemo(
125
+ () => ({
126
+ duration: 1,
127
+ stagger: 0.5,
128
+ }),
129
+ []
130
+ );
131
+
132
+ return (
133
+ <h1>
134
+ <SplitTextRotate text="Hello World" rotateOptions={rotateOptions} />
135
+ </h1>
136
+ );
137
+ }
138
+ ```
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@matteodicristofalo/text-animations",
3
+ "version": "1.0.0",
4
+ "description": "React library to animate text",
5
+ "scripts": {
6
+ "build": "rimraf dist && rollup -c",
7
+ "storybook": "storybook dev -p 6006"
8
+ },
9
+ "dependencies": {
10
+ "clsx": "^2.1.1"
11
+ },
12
+ "devDependencies": {
13
+ "@chromatic-com/storybook": "^3.2.2",
14
+ "@rollup/plugin-node-resolve": "^15.3.0",
15
+ "@rollup/plugin-typescript": "^12.1.1",
16
+ "@storybook/addon-essentials": "^8.4.6",
17
+ "@storybook/addon-interactions": "^8.4.6",
18
+ "@storybook/addon-onboarding": "^8.4.6",
19
+ "@storybook/blocks": "^8.4.6",
20
+ "@storybook/react": "^8.4.6",
21
+ "@storybook/react-vite": "^8.4.6",
22
+ "@storybook/test": "^8.4.6",
23
+ "@types/node": "^20",
24
+ "@types/react": "^18",
25
+ "@types/react-dom": "^18",
26
+ "rimraf": "^6.0.1",
27
+ "rollup": "^4.28.0",
28
+ "rollup-plugin-peer-deps-external": "^2.2.4",
29
+ "rollup-plugin-postcss": "^4.0.2",
30
+ "sass": "^1.81.0",
31
+ "storybook": "^8.4.6",
32
+ "tslib": "^2.8.1",
33
+ "typescript": "^5.7.2",
34
+ "vite-tsconfig-paths": "^5.1.3"
35
+ },
36
+ "peerDependencies": {
37
+ "react": "^18.3.1",
38
+ "react-dom": "^18.3.1"
39
+ },
40
+ "type": "module",
41
+ "types": "dist/index.d.ts",
42
+ "exports": {
43
+ ".": "./dist/index.js",
44
+ "./index.css": "./dist/index.css"
45
+ },
46
+ "author": "Matteo Di Cristofalo",
47
+ "license": "ISC",
48
+ "homepage": "https://github.com/matteodicristofalo/text-animations",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git@github.com:matteodicristofalo/text-animations.git"
52
+ }
53
+ }
@@ -0,0 +1,21 @@
1
+ import resolve from '@rollup/plugin-node-resolve';
2
+ import typescript from '@rollup/plugin-typescript';
3
+ import peerDepsExternal from 'rollup-plugin-peer-deps-external';
4
+ import postCss from 'rollup-plugin-postcss';
5
+
6
+ export default {
7
+ input: 'src/index.ts',
8
+ output: {
9
+ dir: 'dist',
10
+ format: 'es',
11
+ },
12
+ plugins: [
13
+ postCss({
14
+ extract: true,
15
+ modules: true,
16
+ }),
17
+ typescript(),
18
+ peerDepsExternal(),
19
+ resolve(),
20
+ ],
21
+ }
@@ -0,0 +1,2 @@
1
+ export { SplitTextReveal } from "./split-text-reveal/split-text-reveal";
2
+ export { SplitTextRotate } from "./split-text-rotate/split-text-rotate";
@@ -0,0 +1,12 @@
1
+ export type SplitTextRevealProps = {
2
+ text: string;
3
+ splitType?: SplitType;
4
+ revealOptions?: Partial<RevealOptions>;
5
+ };
6
+
7
+ type SplitType = "sentence" | "word" | "char";
8
+
9
+ type RevealOptions = {
10
+ duration: number;
11
+ stagger: number;
12
+ };
@@ -0,0 +1,26 @@
1
+ .text {
2
+ position: relative;
3
+
4
+ .container {
5
+ display: inline-flex;
6
+ overflow: hidden;
7
+
8
+ span {
9
+ min-width: 0.25em;
10
+ transform: translateY(100%);
11
+ transition-delay: var(--var-delay);
12
+ transition-duration: var(--var-duration);
13
+ transition-property: transform;
14
+ transition-timing-function: ease-out;
15
+ will-change: transform;
16
+ }
17
+ }
18
+
19
+ &.animate {
20
+ .container {
21
+ span {
22
+ transform: translateY(0);
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,34 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { SplitTextReveal } from "./split-text-reveal";
3
+
4
+ const meta = {
5
+ title: "SplitTextReveal",
6
+ component: SplitTextReveal,
7
+ parameters: {
8
+ layout: "centered",
9
+ },
10
+ argTypes: {
11
+ text: {
12
+ control: "text",
13
+ },
14
+ splitType: {
15
+ control: "radio",
16
+ options: ["char", "word", "sentence"],
17
+ },
18
+ revealOptions: {
19
+ control: {
20
+ type: "object",
21
+ },
22
+ },
23
+ },
24
+ } satisfies Meta<typeof SplitTextReveal>;
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ export const Default: Story = {
30
+ args: {
31
+ text: "Here is the revealed text",
32
+ splitType: "char",
33
+ },
34
+ };
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import React, { useMemo, useRef } from "react";
4
+ import { round } from "@utils/numbers";
5
+ import { characters, sentences, words } from "@utils/text";
6
+ import { useIntersectionObserver } from "@hooks";
7
+ import { SplitTextRevealProps } from "./split-test-reveal.types";
8
+ import clsx from "clsx";
9
+ import styles from "./split-text-reveal.module.scss";
10
+
11
+ const DEFAULT_DURATION = 0.5;
12
+ const DEFAULT_STAGGER = 0.01;
13
+
14
+ export function SplitTextReveal({
15
+ text,
16
+ splitType = "char",
17
+ revealOptions,
18
+ }: SplitTextRevealProps) {
19
+ const ref = useRef(null);
20
+ const memoizedOptions = useMemo(() => ({ once: true, threshold: 0.1 }), []);
21
+ const isInView = useIntersectionObserver(ref, memoizedOptions);
22
+ const transitionDuration = revealOptions?.duration || DEFAULT_DURATION;
23
+ const transitionStagger = revealOptions?.stagger || DEFAULT_STAGGER;
24
+
25
+ const splitFn = useMemo(() => {
26
+ switch (splitType) {
27
+ case "sentence":
28
+ return sentences;
29
+ case "word":
30
+ return words;
31
+ case "char":
32
+ return characters;
33
+ }
34
+ }, [splitType]);
35
+
36
+ return (
37
+ <span
38
+ ref={ref}
39
+ className={clsx(styles["text"], { [styles["animate"]]: isInView })}
40
+ style={
41
+ {
42
+ "--var-duration": `${transitionDuration}s`,
43
+ } as React.CSSProperties
44
+ }
45
+ >
46
+ {splitFn(text).map((el, i) => (
47
+ <span key={i} className={styles["container"]}>
48
+ <span
49
+ style={
50
+ {
51
+ "--var-delay": `${round(transitionStagger * i, 3)}s`,
52
+ } as React.CSSProperties
53
+ }
54
+ >
55
+ {el}
56
+ </span>
57
+ </span>
58
+ ))}
59
+ </span>
60
+ );
61
+ }
@@ -0,0 +1,34 @@
1
+ .text {
2
+ display: block;
3
+ overflow: hidden;
4
+ position: relative;
5
+
6
+ .container {
7
+ display: flex;
8
+
9
+ &:nth-of-type(2) {
10
+ position: absolute;
11
+ top: 0;
12
+ transform: translateY(100%);
13
+ }
14
+
15
+ .char {
16
+ display: inline-block;
17
+ min-width: 0.25em;
18
+ transition-delay: var(--var-delay-out);
19
+ transition-duration: var(--var-duration);
20
+ transition-property: transform;
21
+ transition-timing-function: ease-out;
22
+ will-change: transform;
23
+ }
24
+ }
25
+
26
+ &:hover {
27
+ .container {
28
+ .char {
29
+ transform: translateY(-100%);
30
+ transition-delay: var(--var-delay-in);
31
+ }
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,29 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { SplitTextRotate } from "./split-text-rotate";
3
+
4
+ const meta = {
5
+ title: "SplitTextRotate",
6
+ component: SplitTextRotate,
7
+ parameters: {
8
+ layout: "centered",
9
+ },
10
+ argTypes: {
11
+ text: {
12
+ control: "text",
13
+ },
14
+ rotateOptions: {
15
+ control: {
16
+ type: "object",
17
+ },
18
+ },
19
+ },
20
+ } satisfies Meta<typeof SplitTextRotate>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ export const Default: Story = {
26
+ args: {
27
+ text: "Rotating text",
28
+ },
29
+ };
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import React, { useCallback } from "react";
4
+ import { round } from "@utils/numbers";
5
+ import { characters } from "@utils/text";
6
+ import { SplitTextRotateProps } from "./split-text-rotate.types";
7
+ import styles from "./split-text-rotate.module.scss";
8
+
9
+ const DEFAULT_DURATION = 0.25;
10
+ const DEFAULT_STAGGER = 0.025;
11
+
12
+ export function SplitTextRotate({ text, rotateOptions }: SplitTextRotateProps) {
13
+ const transitionDuration = rotateOptions?.duration || DEFAULT_DURATION;
14
+ const transitionStagger = rotateOptions?.stagger || DEFAULT_STAGGER;
15
+ const splittedText = characters(text);
16
+
17
+ const delays = useCallback(
18
+ (index: number) => {
19
+ const noElementsZeroBased = splittedText.length - 1;
20
+ return {
21
+ in: round(transitionStagger * index, 3),
22
+ out: round(transitionStagger * (noElementsZeroBased - index), 3),
23
+ };
24
+ },
25
+ [splittedText, transitionStagger]
26
+ );
27
+
28
+ return (
29
+ <span
30
+ className={styles["text"]}
31
+ style={
32
+ {
33
+ "--var-duration": `${transitionDuration}s`,
34
+ } as React.CSSProperties
35
+ }
36
+ >
37
+ {Array.from({ length: 2 }).map((_, i) => (
38
+ <span key={i} className={styles["container"]}>
39
+ {splittedText.map((char, i) => {
40
+ const charDelays = delays(i);
41
+ return (
42
+ <span
43
+ key={i}
44
+ className={styles["char"]}
45
+ style={
46
+ {
47
+ "--var-delay-in": `${charDelays.in}s`,
48
+ "--var-delay-out": `${charDelays.out}s`,
49
+ } as React.CSSProperties
50
+ }
51
+ >
52
+ {char}
53
+ </span>
54
+ );
55
+ })}
56
+ </span>
57
+ ))}
58
+ </span>
59
+ );
60
+ }
@@ -0,0 +1,9 @@
1
+ export type SplitTextRotateProps = {
2
+ text: string;
3
+ rotateOptions?: Partial<RotateOptions>;
4
+ };
5
+
6
+ type RotateOptions = {
7
+ duration: number;
8
+ stagger: number;
9
+ };
@@ -0,0 +1,4 @@
1
+ declare module "*.module.scss" {
2
+ const classes: { [key: string]: string };
3
+ export default classes;
4
+ }
@@ -0,0 +1 @@
1
+ export { useIntersectionObserver } from "./intersection-overser/intersection-observer";
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, RefObject } from "react";
4
+
5
+ type Options = IntersectionObserverInit & {
6
+ once?: boolean;
7
+ };
8
+
9
+ export function useIntersectionObserver(
10
+ ref: RefObject<HTMLElement>,
11
+ options?: Options
12
+ ): boolean {
13
+ const [isIntersecting, setIntersecting] = useState(false);
14
+
15
+ useEffect(() => {
16
+ if (!ref.current) return;
17
+
18
+ const observer = new IntersectionObserver(
19
+ ([entry]) => {
20
+ if (options?.once) {
21
+ if (entry.isIntersecting) {
22
+ setIntersecting(true);
23
+ observer.disconnect();
24
+ }
25
+ } else {
26
+ setIntersecting(entry.isIntersecting);
27
+ }
28
+ },
29
+ {
30
+ threshold: 0.75,
31
+ ...options,
32
+ }
33
+ );
34
+
35
+ observer.observe(ref.current);
36
+
37
+ return () => observer.disconnect();
38
+ }, [ref, options]);
39
+
40
+ return isIntersecting;
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./components";
@@ -0,0 +1,3 @@
1
+ export function round(num: number, digits: number): number {
2
+ return parseFloat(num.toFixed(digits));
3
+ }
@@ -0,0 +1,19 @@
1
+ export function sentences(text: string) {
2
+ return segment(text, "sentence");
3
+ }
4
+
5
+ export function words(text: string) {
6
+ return segment(text, "word");
7
+ }
8
+
9
+ export function characters(text: string) {
10
+ return segment(text, "grapheme");
11
+ }
12
+
13
+ export function segment(
14
+ text: string,
15
+ granularity: "sentence" | "word" | "grapheme"
16
+ ) {
17
+ const segmenter = new Intl.Segmenter(undefined, { granularity });
18
+ return Array.from(segmenter.segment(text), (segment) => segment.segment);
19
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "declaration": true,
4
+ "esModuleInterop": true,
5
+ "jsx": "react",
6
+ "lib": ["esnext", "dom"],
7
+ "outDir": "dist",
8
+ "paths": {
9
+ "@hooks": ["./src/hooks/index"],
10
+ "@utils/*": ["./src/utils/*"]
11
+ }
12
+ }
13
+ }