TextScramble
При наведении текст “скремблируется” — символы проходят через случайные значения, прежде чем вернуться к исходному тексту.
Интерактивное демо
Наведите курсор Эффект матрицы
Пропсы
12
0.600
<script lang="ts">
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
import { onMount } from "svelte";
import type { Snippet } from "svelte";
import { registerPluginOnce } from "$lib/helpers/gsap";
import { cn } from "$lib/utils/cn";
type ComponentProps = {
children?: Snippet;
class?: string;
hoverTarget?: HTMLElement | null;
scrambleDuration?: number;
stagger?: number;
cycles?: number;
characters?: string;
[prop: string]: unknown;
};
let {
children,
class: className = "",
hoverTarget = null,
scrambleDuration = 0.6,
stagger = 0.03,
cycles = 12,
characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*",
...restProps
}: ComponentProps = $props();
onMount(() => {
registerPluginOnce(SplitText);
});
let wrapperRef: HTMLSpanElement | undefined;
let splitInstance: SplitText | null = null;
let hoverTimeline: gsap.core.Timeline | null = null;
const attachWrapperRef = (node: HTMLSpanElement) => {
wrapperRef = node;
return () => {
if (wrapperRef === node) {
wrapperRef = undefined;
}
};
};
const getRandomChar = (pool: string) => {
if (!pool.length) return "";
const index = Math.floor(Math.random() * pool.length);
return pool[index] ?? "";
};
const createScrambleTimeline = (nodes: HTMLElement[]) => {
if (!nodes.length) return null;
const pool = characters.length
? characters
: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const timeline = gsap.timeline({ paused: true });
const totalDuration = Math.max(0.1, scrambleDuration);
const stepCount = Math.max(1, Math.floor(cycles));
const stepDuration = totalDuration / stepCount;
nodes.forEach((node, index) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dataset values are typed as string by this project's DOM lib config, but are genuinely undefined until set below, so the fallback chain is required at runtime.
const finalChar = node.dataset.originalChar ?? node.textContent ?? "";
const charTimeline = gsap.timeline();
if (finalChar.trim().length === 0) {
charTimeline.call(() => {
node.textContent = finalChar;
});
} else {
for (let i = 0; i < stepCount; i += 1) {
charTimeline.call(() => {
node.textContent = getRandomChar(pool);
});
charTimeline.to({}, { duration: stepDuration });
}
charTimeline.call(() => {
node.textContent = finalChar;
});
}
timeline.add(charTimeline, index * stagger);
});
return timeline;
};
$effect(() => {
if (typeof window === "undefined") return;
if (!wrapperRef) return;
const node = wrapperRef;
const target = hoverTarget ?? node;
hoverTimeline?.kill();
hoverTimeline = null;
splitInstance?.revert();
const ctx = gsap.context(() => {
const split = SplitText.create(node, {
type: "chars",
reduceWhiteSpace: false,
charsClass: "inline-block",
});
splitInstance = split;
const charNodes = split.chars as HTMLElement[];
charNodes.forEach((charNode) => {
charNode.style.display = "inline-block";
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- fallback keeps the assigned type as string; dataset values must not be null.
charNode.dataset.originalChar = charNode.textContent ?? "";
if (!charNode.textContent.trim()) {
charNode.style.whiteSpace = "pre";
charNode.style.pointerEvents = "none";
}
});
hoverTimeline ??= createScrambleTimeline(charNodes);
const handleEnter = () => {
hoverTimeline ??= createScrambleTimeline(charNodes);
hoverTimeline?.restart();
};
const handleLeave = () => {
hoverTimeline?.progress(1);
};
target.addEventListener("mouseenter", handleEnter);
target.addEventListener("mouseleave", handleLeave);
return () => {
target.removeEventListener("mouseenter", handleEnter);
target.removeEventListener("mouseleave", handleLeave);
};
}, node);
return () => {
ctx.revert();
hoverTimeline = null;
splitInstance?.revert();
splitInstance = null;
};
});
</script>
<span
{...restProps}
class={cn("font-inherit inline-block align-baseline text-inherit", className)}
{@attach attachWrapperRef}
>
{@render children?.()}
</span><script lang="ts">
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
import { onMount } from "svelte";
import type { Snippet } from "svelte";
import { registerPluginOnce } from "$lib/helpers/gsap";
import { cn } from "$lib/utils/cn";
type ComponentProps = {
children?: Snippet;
class?: string;
hoverTarget?: HTMLElement | null;
scrambleDuration?: number;
stagger?: number;
cycles?: number;
characters?: string;
[prop: string]: unknown;
};
let {
children,
class: className = "",
hoverTarget = null,
scrambleDuration = 0.6,
stagger = 0.03,
cycles = 12,
characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*",
...restProps
}: ComponentProps = $props();
onMount(() => {
registerPluginOnce(SplitText);
});
let wrapperRef: HTMLSpanElement | undefined;
let splitInstance: SplitText | null = null;
let hoverTimeline: gsap.core.Timeline | null = null;
const attachWrapperRef = (node: HTMLSpanElement) => {
wrapperRef = node;
return () => {
if (wrapperRef === node) {
wrapperRef = undefined;
}
};
};
const getRandomChar = (pool: string) => {
if (!pool.length) return "";
const index = Math.floor(Math.random() * pool.length);
return pool[index] ?? "";
};
const createScrambleTimeline = (nodes: HTMLElement[]) => {
if (!nodes.length) return null;
const pool = characters.length
? characters
: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const timeline = gsap.timeline({ paused: true });
const totalDuration = Math.max(0.1, scrambleDuration);
const stepCount = Math.max(1, Math.floor(cycles));
const stepDuration = totalDuration / stepCount;
nodes.forEach((node, index) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dataset values are typed as string by this project's DOM lib config, but are genuinely undefined until set below, so the fallback chain is required at runtime.
const finalChar = node.dataset.originalChar ?? node.textContent ?? "";
const charTimeline = gsap.timeline();
if (finalChar.trim().length === 0) {
charTimeline.call(() => {
node.textContent = finalChar;
});
} else {
for (let i = 0; i < stepCount; i += 1) {
charTimeline.call(() => {
node.textContent = getRandomChar(pool);
});
charTimeline.to({}, { duration: stepDuration });
}
charTimeline.call(() => {
node.textContent = finalChar;
});
}
timeline.add(charTimeline, index * stagger);
});
return timeline;
};
$effect(() => {
if (typeof window === "undefined") return;
if (!wrapperRef) return;
const node = wrapperRef;
const target = hoverTarget ?? node;
hoverTimeline?.kill();
hoverTimeline = null;
splitInstance?.revert();
const ctx = gsap.context(() => {
const split = SplitText.create(node, {
type: "chars",
reduceWhiteSpace: false,
charsClass: "inline-block",
});
splitInstance = split;
const charNodes = split.chars as HTMLElement[];
charNodes.forEach((charNode) => {
charNode.style.display = "inline-block";
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- fallback keeps the assigned type as string; dataset values must not be null.
charNode.dataset.originalChar = charNode.textContent ?? "";
if (!charNode.textContent.trim()) {
charNode.style.whiteSpace = "pre";
charNode.style.pointerEvents = "none";
}
});
hoverTimeline ??= createScrambleTimeline(charNodes);
const handleEnter = () => {
hoverTimeline ??= createScrambleTimeline(charNodes);
hoverTimeline?.restart();
};
const handleLeave = () => {
hoverTimeline?.progress(1);
};
target.addEventListener("mouseenter", handleEnter);
target.addEventListener("mouseleave", handleLeave);
return () => {
target.removeEventListener("mouseenter", handleEnter);
target.removeEventListener("mouseleave", handleLeave);
};
}, node);
return () => {
ctx.revert();
hoverTimeline = null;
splitInstance?.revert();
splitInstance = null;
};
});
</script>
<span
{...restProps}
class={cn("font-inherit inline-block align-baseline text-inherit", className)}
{@attach attachWrapperRef}
>
{@render children?.()}
</span>Использование
Базовое использование
<TextScramble>
<span class="text-xl font-bold">Наведите на меня</span>
</TextScramble><TextScramble>
<span class="text-xl font-bold">Наведите на меня</span>
</TextScramble>С кастомными символами
<TextScramble characters="01">
<span>Бинарный текст</span>
</TextScramble><TextScramble characters="01">
<span>Бинарный текст</span>
</TextScramble>С внешним триггером
<script>
let triggerEl: HTMLElement;
</script>
<button bind:this={triggerEl} class="btn">
<TextScramble hoverTarget={triggerEl}>
<span>Кнопка</span>
</TextScramble>
</button><script>
let triggerEl: HTMLElement;
</script>
<button bind:this={triggerEl} class="btn">
<TextScramble hoverTarget={triggerEl}>
<span>Кнопка</span>
</TextScramble>
</button>Пропсы
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
scrambleDuration | number | 0.6 | Общая длительность анимации (с) |
stagger | number | 0.03 | Задержка между символами |
cycles | number | 12 | Кол-во смен символов |
characters | string | "A-Z0-9!@#$" | Символы для подстановки |
hoverTarget | HTMLElement null | null | Внешний элемент-триггер |
class | string | — | Дополнительные классы |
