Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ From **character-level typing effects** to **complex UI block animations**, it p

- **Lightweight & Performant** – minimal footprint, no heavy deps
- **Robust & Tested** – 100% test coverage with Jest + React Testing Library
- **Flexible API** – animate by character, word, or line, supporting both plain text and rich React nodes.
- **Flexible API** – animate by character or word, supporting both plain text and rich React nodes.
- **Presets & Motion** – use built-in effects or define your property values
- **Developer-Friendly** – JSDoc, examples, TypeScript support

Expand Down Expand Up @@ -44,7 +44,7 @@ Instantly animates `"Hello World!"` with fade + slide.

### `TextMotion`

Animate **plain text strings** or **any React children** (mixed tags, custom components, blocks) with per-character, word, or line animations.
Animate **plain text strings** or **any React children** (mixed tags, custom components, blocks) with per-character or word animations.

```tsx
// Animating a plain string
Expand Down Expand Up @@ -93,19 +93,19 @@ Animate **plain text strings** or **any React children** (mixed tags, custom com

### TextMotion Props

| Prop | Type | Default | Required | Description |
| ------------------ | ------------------------------------ | --------------- | ----------------------- | ---------------------------------------------------------------------- |
| `children` | `ReactNode` | `-` | Yes | Content to animate. Can be a string, a number, or any React element. |
| `as` | `string` | `"span"` | No | HTML tag wrapper |
| `split` | `"character" \| "word" \| "line"` | `"character"` | No | Text split granularity. `line` is only applicable for string children. |
| `trigger` | `"on-load" \| "scroll"` | `"scroll"` | No | When animation starts |
| `repeat` | `boolean` | `true` | No | Repeat entire animation |
| `initialDelay` | `number` | `0` | No | Initial delay before animation starts (in `s`) |
| `animationOrder` | `"first-to-last" \| "last-to-first"` | `first-to-last` | No | Order of the animation sequence |
| `motion` | `Motion` | `-` | Yes (if `preset` unset) | Custom animation config |
| `preset` | `Preset[]` | `-` | Yes (if `motion` unset) | Predefined animation presets |
| `onAnimationStart` | `() => void` | `-` | No | Callback function that is called when the animation starts |
| `onAnimationEnd` | `() => void` | `-` | No | Callback function that is called when the animation ends |
| Prop | Type | Default | Required | Description |
| ------------------ | ------------------------------------ | --------------- | ----------------------- | -------------------------------------------------------------------- |
| `children` | `ReactNode` | `-` | Yes | Content to animate. Can be a string, a number, or any React element. |
| `as` | `string` | `"span"` | No | HTML tag wrapper |
| `split` | `"character" \| "word"` | `"character"` | No | Text split granularity. |
| `trigger` | `"on-load" \| "scroll"` | `"scroll"` | No | When animation starts |
| `repeat` | `boolean` | `true` | No | Repeat entire animation |
| `initialDelay` | `number` | `0` | No | Initial delay before animation starts (in `s`) |
| `animationOrder` | `"first-to-last" \| "last-to-first"` | `first-to-last` | No | Order of the animation sequence |
| `motion` | `Motion` | `-` | Yes (if `preset` unset) | Custom animation config |
| `preset` | `Preset[]` | `-` | Yes (if `motion` unset) | Predefined animation presets |
| `onAnimationStart` | `() => void` | `-` | No | Callback function that is called when the animation starts |
| `onAnimationEnd` | `() => void` | `-` | No | Callback function that is called when the animation ends |

## Presets

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-textmotion",
"version": "0.0.9",
"version": "0.0.10",
"description": "Lightweight yet powerful library that provides variable animation effects for React applications.",
"type": "module",
"main": "dist/index.cjs.js",
Expand Down
26 changes: 13 additions & 13 deletions src/components/TextMotion/TextMotion.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,20 +262,20 @@ describe('TextMotion with different split options (component-level via hook mock
expect(animatedSpans[3].textContent).toBe('!');
});

it('should warn when using split="line" with non-string children', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
// it('should warn when using split="line" with non-string children', () => {
// const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});

render(
<TextMotion trigger="on-load" split="line">
Hello <strong>World</strong>
</TextMotion>
);
// render(
// <TextMotion trigger="on-load" split="line">
// Hello <strong>World</strong>
// </TextMotion>
// );

expect(consoleWarnSpy).toHaveBeenCalledWith(
'TextMotion validation warnings:',
expect.arrayContaining(['split="line" is only applicable when children is a string.'])
);
// expect(consoleWarnSpy).toHaveBeenCalledWith(
// 'TextMotion validation warnings:',
// expect.arrayContaining(['split="line" is only applicable when children is a string.'])
// );

consoleWarnSpy.mockRestore();
});
// consoleWarnSpy.mockRestore();
// });
});
36 changes: 24 additions & 12 deletions src/components/TextMotion/TextMotion.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import '../../styles/animations.scss';
import '../../styles/motion.scss';

import { type FC, memo, useEffect } from 'react';
import { type FC, memo, useEffect, useRef } from 'react';

import { DEFAULT_ARIA_LABEL } from '../../constants';
import { useTextMotionAnimation } from '../../hooks/useTextMotionAnimation';
import { useValidation } from '../../hooks/useValidation';
import type { TextMotionProps } from '../../types';
import { getAriaLabel } from '../../utils/accessibility';

/**
* @description
* `TextMotion` is a component that animates its children by applying motion presets or custom motion configurations.
* It can animate text nodes by splitting them into characters, words or lines, and can also animate other React elements.
* It can animate text nodes by splitting them into characters or words, and can also animate other React elements.
*
* @param {ReactNode} children - The content to animate. Can be a string, a number, or any React element.
* @param {ElementType} [as='span'] - The HTML tag to render. Defaults to `span`.
* @param {Split} [split='character'] - Defines how the text is split for animation (`character`, `word`, or `line`). `line` is only applicable for string children. Defaults to `'character'`.
* @param {Split} [split='character'] - Defines how the text is split for animation (`character` or `word`). Defaults to `'character'`.
* @param {Trigger} [trigger='scroll'] - Defines when the animation should start. 'on-load' starts the animation immediately. 'scroll' starts the animation only when the component enters the viewport. Defaults to `'scroll'`.
* @param {boolean} [repeat=true] - Determines if the animation should repeat every time it enters the viewport. Only applicable when `trigger` is `'scroll'`. Defaults to `true`.
* @param {number} [initialDelay=0] - The initial delay before the animation starts, in seconds. Defaults to `0`.
Expand Down Expand Up @@ -69,28 +70,39 @@ import type { TextMotionProps } from '../../types';
*/
export const TextMotion: FC<TextMotionProps> = memo(props => {
const { as: Tag = 'span', children, onAnimationStart } = props;

useValidation({ componentName: 'TextMotion', props });

const { shouldAnimate, targetRef, animatedChildren, text } = useTextMotionAnimation(props);

const onAnimationStartRef = useRef(onAnimationStart);

useEffect(() => {
onAnimationStartRef.current = onAnimationStart;
}, [onAnimationStart]);

useEffect(() => {
if (shouldAnimate) {
onAnimationStart?.();
onAnimationStartRef.current?.();
}
}, [shouldAnimate, onAnimationStart]);
}, [shouldAnimate]);

if (!shouldAnimate) {
const ariaProps = getAriaLabel(text || DEFAULT_ARIA_LABEL);

return (
<Tag ref={targetRef} className="text-motion-inanimate" aria-label={text || DEFAULT_ARIA_LABEL}>
<Tag ref={targetRef} className="text-motion-inanimate" {...ariaProps}>
{children}
</Tag>
);
}

return (
<Tag ref={targetRef} className="text-motion" aria-label={text || DEFAULT_ARIA_LABEL}>
{animatedChildren}
</Tag>
);
return (() => {
const ariaProps = getAriaLabel(text || DEFAULT_ARIA_LABEL);

return (
<Tag ref={targetRef} className="text-motion" {...ariaProps}>
{animatedChildren}
</Tag>
);
})();
});
18 changes: 12 additions & 6 deletions src/hooks/useAnimatedChildren/useAnimatedChildren.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Children, cloneElement, type ReactNode, useMemo } from 'react';
import { Children, cloneElement, type MutableRefObject, type ReactNode, useEffect, useMemo, useRef } from 'react';

import type { AnimationOrder, Motion } from '../../types';
import { countNodes } from '../../utils/countNodes';
Expand Down Expand Up @@ -41,6 +41,12 @@ export const useAnimatedChildren = ({
resolvedMotion,
onAnimationEnd,
}: UseAnimatedChildrenProps): ReactNode[] => {
const onAnimationEndRef = useRef(onAnimationEnd);

useEffect(() => {
onAnimationEndRef.current = onAnimationEnd;
}, [onAnimationEnd]);

const animatedChildren = useMemo(() => {
const totalNodes = countNodes(splittedNode);

Expand All @@ -51,11 +57,11 @@ export const useAnimatedChildren = ({
animationOrder,
resolvedMotion,
totalNodes,
onAnimationEnd
onAnimationEndRef
);

return nodes;
}, [splittedNode, initialDelay, animationOrder, resolvedMotion, onAnimationEnd]);
}, [splittedNode, initialDelay, animationOrder, resolvedMotion]);

return animatedChildren;
};
Expand All @@ -67,7 +73,7 @@ const wrapWithAnimatedSpan = (
animationOrder: AnimationOrder,
resolvedMotion: Motion,
totalNodes: number,
onAnimationEnd?: () => void
onAnimationEndRef?: MutableRefObject<(() => void) | undefined>
): WrapResult => {
let sequenceIndex = currentSequenceIndex;

Expand All @@ -76,7 +82,7 @@ const wrapWithAnimatedSpan = (
const currentIndex = sequenceIndex++;
const calculatedSequenceIndex = calculateSequenceIndex(currentIndex, totalNodes, animationOrder);
const isLast = isLastNode(calculatedSequenceIndex, totalNodes);
const handleAnimationEnd = isLast ? onAnimationEnd : undefined;
const handleAnimationEnd = isLast ? () => onAnimationEndRef?.current?.() : undefined;
const { style } = generateAnimation(resolvedMotion, calculatedSequenceIndex, initialDelay);

return <AnimatedSpan key={key} text={String(node)} style={style} onAnimationEnd={handleAnimationEnd} />;
Expand All @@ -91,7 +97,7 @@ const wrapWithAnimatedSpan = (
animationOrder,
resolvedMotion,
totalNodes,
onAnimationEnd
onAnimationEndRef
);
sequenceIndex = nextSequenceIndex;

Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useValidation/useValidation.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('useValidation hook', () => {

expect(consoleErrorSpy).toHaveBeenCalledWith(
'TextMotion validation errors:',
expect.arrayContaining(['split prop must be one of: character, word, line'])
expect.arrayContaining(['split prop must be one of: character, word'])
);

consoleErrorSpy.mockRestore();
Expand Down
18 changes: 9 additions & 9 deletions src/hooks/useValidation/validation.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,18 @@ describe('validation utility', () => {
expect(warnings).toContain('children prop is empty');
});

it('should return a warning for "line" split with non-string children', () => {
const props: TextMotionProps = {
children: [<div key="1" />],
split: 'line',
};
const { warnings } = validateTextMotionProps(props);
// it('should return a warning for "line" split with non-string children', () => {
// const props: TextMotionProps = {
// children: [<div key="1" />],
// split: 'line',
// };
// const { warnings } = validateTextMotionProps(props);

expect(warnings).toContain('split="line" is only applicable when children is a string.');
});
// expect(warnings).toContain('split="line" is only applicable when children is a string.');
// });

it('should accept valid split values', () => {
(['character', 'word', 'line'] as const).forEach(split => {
(['character', 'word'] as const).forEach(split => {
const props: TextMotionProps = { children: 'hello', split };
const { errors } = validateTextMotionProps(props);

Expand Down
10 changes: 5 additions & 5 deletions src/hooks/useValidation/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ export const validateTextMotionProps = (props: TextMotionProps): ValidationResul
warnings.push('children prop is empty');
}

if (props.split === 'line' && typeof props.children !== 'string') {
warnings.push('split="line" is only applicable when children is a string.');
}
// if (props.split === 'line' && typeof props.children !== 'string') {
// warnings.push('split="line" is only applicable when children is a string.');
// }

const common = validateCommonProps(props);

Expand All @@ -45,8 +45,8 @@ const validateCommonProps = (props: Partial<TextMotionProps>): ValidationResult
const errors: string[] = [];
const warnings: string[] = [];

if (props.split !== undefined && !['character', 'word', 'line'].includes(props.split)) {
errors.push('split prop must be one of: character, word, line');
if (props.split !== undefined && !['character', 'word'].includes(props.split)) {
errors.push('split prop must be one of: character, word');
}

if (props.trigger !== undefined && !['on-load', 'scroll'].includes(props.trigger)) {
Expand Down
3 changes: 1 addition & 2 deletions src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ import type {
* Defines how text is split for animation.
* - `character`: Splits the text into individual characters.
* - `word`: Splits the text into words.
* - `line`: Splits the text into lines.
*/
export type Split = 'character' | 'word' | 'line';
export type Split = 'character' | 'word';

/**
* @description
Expand Down
11 changes: 11 additions & 0 deletions src/utils/accessibility/accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { getAriaLabel } from './accessibility';

describe('getAriaLabel', () => {
it('should return aria-label when text is provided', () => {
expect(getAriaLabel('Hello')).toEqual({ 'aria-label': 'Hello' });
});

it('should return empty object when text is empty', () => {
expect(getAriaLabel('')).toEqual({});
});
});
9 changes: 9 additions & 0 deletions src/utils/accessibility/accessibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Generates aria-label attribute for accessibility
*
* @param text - The text to be used as aria-label
* @returns Object with aria-label attribute
*/
export const getAriaLabel = (text: string): { 'aria-label'?: string } => {
return text.trim() ? { 'aria-label': text } : {};
};
1 change: 1 addition & 0 deletions src/utils/accessibility/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './accessibility';
2 changes: 1 addition & 1 deletion src/utils/splitText/splitText.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('splitText utility', () => {
const testCases: [Split, string, string[]][] = [
['character', 'Hello', ['H', 'e', 'l', 'l', 'o']],
['word', 'Hello World', ['Hello', ' ', 'World']],
['line', 'Hello\nWorld', ['Hello', '\n', 'World']],
// ['line', 'Hello\nWorld', ['Hello', '\n', 'World']],
];

it.each(testCases)('should split the text by %s', (split, input, expected) => {
Expand Down
6 changes: 3 additions & 3 deletions src/utils/splitText/splitText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ import type { Split } from '../../types';
* `splitText` is a utility function that splits a string into an array of substrings based on the specified split type.
*
* @param {string} text - The text to be split.
* @param {Split} split - The split type (`character`, `word`, or `line`).
* @param {Split} split - The split type (`character` or `word`).
*
* @returns {string[]} An array of substrings.
*/
export const splitText = (text: string, split: Split): string[] => {
switch (split) {
case 'word':
return text.split(/(\s+)/).filter(Boolean);
case 'line':
return text.split(/(\n)/).filter(Boolean);
// case 'line':
// return text.split(/(\n)/).filter(Boolean);
case 'character':
default:
return text.split('');
Expand Down