Рандомизированный текст
Компонент показывает текст с последовательным появлением слов или символов. Исходный текст сохраняется в DOM для доступности, анимация не требует дополнительных зависимостей.
Интерактивное демо
Новые идеи начинаются с простых слов.
Пропсы
Разбиение
0.200
0.800
<script lang="ts">
import { tick } from 'svelte';
import { cn } from '$lib/utils';
import { readNormalizedTextContent, segmentText, splitGraphemes } from '$lib/utils/text-utils';
import type { Snippet } from 'svelte';
type ElementName = 'span' | 'div' | 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
type Props = {
children: Snippet;
class?: string;
style?: string;
as?: ElementName;
split?: 'words' | 'chars';
delay?: number;
duration?: number;
trigger?: boolean;
triggerOnView?: boolean;
once?: boolean;
};
let {
children,
class: className,
style = '',
as = 'span',
split = 'words',
delay = 0.2,
duration = 1.2,
trigger = true,
triggerOnView = false,
once = true
}: Props = $props();
let sourceElement = $state<HTMLSpanElement | null>(null);
let sourceText = $state('');
let hasBeenViewed = $state(false);
let observer: IntersectionObserver | undefined;
async function sync() {
await tick();
sourceText = readNormalizedTextContent(sourceElement);
}
function runSync() {
void sync();
}
function onMount(node: HTMLElement) {
const frame = requestAnimationFrame(runSync);
const mutationObserver = new MutationObserver(runSync);
mutationObserver.observe(node, { childList: true, subtree: true, characterData: true });
if (triggerOnView) {
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
hasBeenViewed = true;
if (once) observer?.disconnect();
}
},
{ threshold: 0.2 }
);
observer.observe(node);
}
return {
destroy() {
cancelAnimationFrame(frame);
mutationObserver.disconnect();
observer?.disconnect();
}
};
}
const isVisible = $derived(trigger && (!triggerOnView || hasBeenViewed));
const tokens = $derived(
split === 'chars'
? splitGraphemes(sourceText).map((value, index) => ({
value,
whitespace: /^\s+$/.test(value),
index
}))
: segmentText(sourceText).map((token, index) => ({
value: token.value,
whitespace: token.kind === 'whitespace',
index
}))
);
const Tag = $derived(as);
</script>
<svelte:element
this={Tag}
use:onMount
class={cn('break-words whitespace-pre-line randomized-text', className)}
style={`--randomized-duration: ${Math.max(duration, 0.01)}s; --randomized-delay: ${Math.max(delay, 0)}s; ${style}`}
>
<span bind:this={sourceElement} class="sr-only">{@render children()}</span>
{#each tokens as token (`${token.index}-${token.value}`)}
{#if token.whitespace}
<span aria-hidden="true" class="whitespace-pre">{token.value}</span>
{:else}
<span
aria-hidden="true"
class:inline-block={split === 'words'}
class:randomized-hidden={!isVisible}
class:randomized-visible={isVisible}
style={`--randomized-index: ${token.index}`}>{token.value}</span
>
{/if}
{/each}
</svelte:element>
<style>
.randomized-text {
display: inline-block;
word-break: break-word;
}
.randomized-text > span:not(.sr-only) {
opacity: 0;
filter: blur(4px);
transform: translateY(0.8em);
}
.randomized-visible {
animation: randomized-enter var(--randomized-duration) cubic-bezier(0.16, 1, 0.3, 1)
calc(var(--randomized-delay) * var(--randomized-index) * 0.35) both;
}
.randomized-hidden {
opacity: 0;
}
@keyframes randomized-enter {
to {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.randomized-text > span:not(.sr-only) {
animation: none;
opacity: 1;
filter: none;
transform: none;
}
}
</style>
<script lang="ts">
import { tick } from 'svelte';
import { cn } from '$lib/utils';
import { readNormalizedTextContent, segmentText, splitGraphemes } from '$lib/utils/text-utils';
import type { Snippet } from 'svelte';
type ElementName = 'span' | 'div' | 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
type Props = {
children: Snippet;
class?: string;
style?: string;
as?: ElementName;
split?: 'words' | 'chars';
delay?: number;
duration?: number;
trigger?: boolean;
triggerOnView?: boolean;
once?: boolean;
};
let {
children,
class: className,
style = '',
as = 'span',
split = 'words',
delay = 0.2,
duration = 1.2,
trigger = true,
triggerOnView = false,
once = true
}: Props = $props();
let sourceElement = $state<HTMLSpanElement | null>(null);
let sourceText = $state('');
let hasBeenViewed = $state(false);
let observer: IntersectionObserver | undefined;
async function sync() {
await tick();
sourceText = readNormalizedTextContent(sourceElement);
}
function runSync() {
void sync();
}
function onMount(node: HTMLElement) {
const frame = requestAnimationFrame(runSync);
const mutationObserver = new MutationObserver(runSync);
mutationObserver.observe(node, { childList: true, subtree: true, characterData: true });
if (triggerOnView) {
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
hasBeenViewed = true;
if (once) observer?.disconnect();
}
},
{ threshold: 0.2 }
);
observer.observe(node);
}
return {
destroy() {
cancelAnimationFrame(frame);
mutationObserver.disconnect();
observer?.disconnect();
}
};
}
const isVisible = $derived(trigger && (!triggerOnView || hasBeenViewed));
const tokens = $derived(
split === 'chars'
? splitGraphemes(sourceText).map((value, index) => ({
value,
whitespace: /^\s+$/.test(value),
index
}))
: segmentText(sourceText).map((token, index) => ({
value: token.value,
whitespace: token.kind === 'whitespace',
index
}))
);
const Tag = $derived(as);
</script>
<svelte:element
this={Tag}
use:onMount
class={cn('break-words whitespace-pre-line randomized-text', className)}
style={`--randomized-duration: ${Math.max(duration, 0.01)}s; --randomized-delay: ${Math.max(delay, 0)}s; ${style}`}
>
<span bind:this={sourceElement} class="sr-only">{@render children()}</span>
{#each tokens as token (`${token.index}-${token.value}`)}
{#if token.whitespace}
<span aria-hidden="true" class="whitespace-pre">{token.value}</span>
{:else}
<span
aria-hidden="true"
class:inline-block={split === 'words'}
class:randomized-hidden={!isVisible}
class:randomized-visible={isVisible}
style={`--randomized-index: ${token.index}`}>{token.value}</span
>
{/if}
{/each}
</svelte:element>
<style>
.randomized-text {
display: inline-block;
word-break: break-word;
}
.randomized-text > span:not(.sr-only) {
opacity: 0;
filter: blur(4px);
transform: translateY(0.8em);
}
.randomized-visible {
animation: randomized-enter var(--randomized-duration) cubic-bezier(0.16, 1, 0.3, 1)
calc(var(--randomized-delay) * var(--randomized-index) * 0.35) both;
}
.randomized-hidden {
opacity: 0;
}
@keyframes randomized-enter {
to {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.randomized-text > span:not(.sr-only) {
animation: none;
opacity: 1;
filter: none;
transform: none;
}
}
</style>
Использование
<script>
import RandomizedText from '$lib/components/ui-registry/randomized-text.svelte';
</script>
<RandomizedText split="words" triggerOnView>
{#snippet children()}Текст появляется по словам.{/snippet}
</RandomizedText><script>
import RandomizedText from '$lib/components/ui-registry/randomized-text.svelte';
</script>
<RandomizedText split="words" triggerOnView>
{#snippet children()}Текст появляется по словам.{/snippet}
</RandomizedText>Пропсы
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
children | Snippet | — | Текстовый контент |
as | span div p h1-h6 | span | Корневой HTML-элемент |
split | words chars | words | Единица анимации |
delay | number | 0.2 | Базовая задержка |
duration | number | 1.2 | Длительность появления |
trigger | boolean | true | Включает анимацию |
triggerOnView | boolean | false | Запуск при появлении во viewport |
once | boolean | true | Не повторять запуск при прокрутке |
