Желейный текст
Компонент циклически «перетекает» между словами: слои текста размываются и слипаются, как капли желе. Эффект построен на SVG-фильтре feColorMatrix с порогом по альфа-каналу и CSS-блюре — без сторонних зависимостей.
Интерактивное демо
Привет, Мир, Opensophy
ПриветМирOpensophy
Пропсы
1.000
0.250
48
Цвет текста
<script lang="ts" module>
// Значения из ./conf и ./helpers в сообщении не пришли: ниже классическая формула
// «gooey text» (порог по альфа-каналу + блюр). Замените на свои при необходимости.
export const THRESHOLD_MATRIX = "1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 255 -140";
export function calculateBlur(visibility: number): number {
return visibility > 0 ? Math.min(8 / visibility - 8, 100) : 100;
}
export function calculateOpacity(visibility: number): number {
return visibility > 0 ? Math.pow(visibility, 0.4) : 0;
}
export type IGooeyText = {
texts: string[];
/** Длительность морфинга, сек */
morphTime?: number;
/** Пауза между морфингами, сек */
cooldownTime?: number;
fontSize?: number;
color?: string;
fontFamily?: string;
fontWeight?: string | number;
width?: number;
height?: number;
/** Inline-стили корневого элемента */
style?: string;
class?: string;
textClass?: string;
};
</script>
<script lang="ts">
let {
texts,
morphTime = 1,
cooldownTime = 0.25,
fontSize = 48,
color = "black",
fontFamily,
fontWeight = "bold",
width = 300,
height = 100,
style = "",
class: className = "",
textClass = "",
}: IGooeyText = $props();
const uid = $props.id();
const filterId = `${uid}-threshold`;
const cycleTime = $derived(cooldownTime + morphTime);
const cooldownFraction = $derived(cooldownTime / cycleTime);
const total = $derived(texts.length);
// «Главные часы»: 0 → texts.length, целая часть — текущий текст, дробная — прогресс цикла
let clock = $state(0);
$effect(() => {
clock = 0;
if (total < 2) return;
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let raf = 0;
let start: number | undefined;
const tick = (now: number) => {
start ??= now;
const elapsed = (now - start) / 1000;
clock = (elapsed / cycleTime) % total;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
function visibilityOf(index: number, c: number): number {
const cycleIndex = Math.floor(c) % total;
const nextIndex = (cycleIndex + 1) % total;
const progressInCycle = c % 1;
let morphProgress = 0;
if (progressInCycle >= cooldownFraction) {
morphProgress = (progressInCycle - cooldownFraction) / (1 - cooldownFraction);
}
if (index === cycleIndex) return 1 - morphProgress;
if (index === nextIndex) return morphProgress;
return 0;
}
const items = $derived(
texts.map((text, index) => {
const v = visibilityOf(index, clock);
const blur = calculateBlur(v);
const opacity = calculateOpacity(v);
return {
text,
opacity,
filter: opacity === 0 ? "none" : `blur(${blur.toFixed(2)}px)`,
};
}),
);
const fontCss = $derived(
`font-size: ${fontSize}px; font-weight: ${fontWeight}; color: ${color};` +
(fontFamily ? ` font-family: ${fontFamily};` : ""),
);
</script>
<div
class="gooey-text {className}"
style="width: {width}px; height: {height}px; {style}"
>
<span class="sr-only">{texts.join(", ")}</span>
{#if total < 2}
<div class="layer" aria-hidden="true">
<span class="item {textClass}" style={fontCss}>{texts[0] ?? ""}</span>
</div>
{:else}
<svg class="defs" width="0" height="0" aria-hidden="true" focusable="false">
<defs>
<filter id={filterId} color-interpolation-filters="sRGB">
<feColorMatrix in="SourceGraphic" type="matrix" values={THRESHOLD_MATRIX} />
</filter>
</defs>
</svg>
<div class="layer" aria-hidden="true" style="filter: url(#{filterId});">
{#each items as item, i (i)}
<span
class="item {textClass}"
style="{fontCss} opacity: {item.opacity}; filter: {item.filter};"
>{item.text}</span>
{/each}
</div>
{/if}
</div>
<style>
.gooey-text {
position: relative;
overflow: hidden;
}
.defs {
position: absolute;
pointer-events: none;
}
.layer {
position: absolute;
inset: 0;
}
.item {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
line-height: 1;
user-select: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style><script lang="ts" module>
// Значения из ./conf и ./helpers в сообщении не пришли: ниже классическая формула
// «gooey text» (порог по альфа-каналу + блюр). Замените на свои при необходимости.
export const THRESHOLD_MATRIX = "1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 255 -140";
export function calculateBlur(visibility: number): number {
return visibility > 0 ? Math.min(8 / visibility - 8, 100) : 100;
}
export function calculateOpacity(visibility: number): number {
return visibility > 0 ? Math.pow(visibility, 0.4) : 0;
}
export type IGooeyText = {
texts: string[];
/** Длительность морфинга, сек */
morphTime?: number;
/** Пауза между морфингами, сек */
cooldownTime?: number;
fontSize?: number;
color?: string;
fontFamily?: string;
fontWeight?: string | number;
width?: number;
height?: number;
/** Inline-стили корневого элемента */
style?: string;
class?: string;
textClass?: string;
};
</script>
<script lang="ts">
let {
texts,
morphTime = 1,
cooldownTime = 0.25,
fontSize = 48,
color = "black",
fontFamily,
fontWeight = "bold",
width = 300,
height = 100,
style = "",
class: className = "",
textClass = "",
}: IGooeyText = $props();
const uid = $props.id();
const filterId = `${uid}-threshold`;
const cycleTime = $derived(cooldownTime + morphTime);
const cooldownFraction = $derived(cooldownTime / cycleTime);
const total = $derived(texts.length);
// «Главные часы»: 0 → texts.length, целая часть — текущий текст, дробная — прогресс цикла
let clock = $state(0);
$effect(() => {
clock = 0;
if (total < 2) return;
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let raf = 0;
let start: number | undefined;
const tick = (now: number) => {
start ??= now;
const elapsed = (now - start) / 1000;
clock = (elapsed / cycleTime) % total;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
function visibilityOf(index: number, c: number): number {
const cycleIndex = Math.floor(c) % total;
const nextIndex = (cycleIndex + 1) % total;
const progressInCycle = c % 1;
let morphProgress = 0;
if (progressInCycle >= cooldownFraction) {
morphProgress = (progressInCycle - cooldownFraction) / (1 - cooldownFraction);
}
if (index === cycleIndex) return 1 - morphProgress;
if (index === nextIndex) return morphProgress;
return 0;
}
const items = $derived(
texts.map((text, index) => {
const v = visibilityOf(index, clock);
const blur = calculateBlur(v);
const opacity = calculateOpacity(v);
return {
text,
opacity,
filter: opacity === 0 ? "none" : `blur(${blur.toFixed(2)}px)`,
};
}),
);
const fontCss = $derived(
`font-size: ${fontSize}px; font-weight: ${fontWeight}; color: ${color};` +
(fontFamily ? ` font-family: ${fontFamily};` : ""),
);
</script>
<div
class="gooey-text {className}"
style="width: {width}px; height: {height}px; {style}"
>
<span class="sr-only">{texts.join(", ")}</span>
{#if total < 2}
<div class="layer" aria-hidden="true">
<span class="item {textClass}" style={fontCss}>{texts[0] ?? ""}</span>
</div>
{:else}
<svg class="defs" width="0" height="0" aria-hidden="true" focusable="false">
<defs>
<filter id={filterId} color-interpolation-filters="sRGB">
<feColorMatrix in="SourceGraphic" type="matrix" values={THRESHOLD_MATRIX} />
</filter>
</defs>
</svg>
<div class="layer" aria-hidden="true" style="filter: url(#{filterId});">
{#each items as item, i (i)}
<span
class="item {textClass}"
style="{fontCss} opacity: {item.opacity}; filter: {item.filter};"
>{item.text}</span>
{/each}
</div>
{/if}
</div>
<style>
.gooey-text {
position: relative;
overflow: hidden;
}
.defs {
position: absolute;
pointer-events: none;
}
.layer {
position: absolute;
inset: 0;
}
.item {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
line-height: 1;
user-select: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>Как это работает
- Каждое слово — отдельный слой с вычисленными
opacityиblur. - «Главные часы» (
requestAnimationFrame) определяют текущий цикл и прогресс морфинга. - Пороговая матрица по альфа-каналу склеивает полупрозрачные слои в единую «каплю».
- При
prefers-reduced-motion: reduceанимация отключается, показывается первый текст.
Использование
Базовое использование
<script>
import GooeyText from '$lib/components/ui-registry/GooeyText.svelte';
</script>
<GooeyText texts={['Быстро', 'Красиво', 'Надёжно']} /><script>
import GooeyText from '$lib/components/ui-registry/GooeyText.svelte';
</script>
<GooeyText texts={['Быстро', 'Красиво', 'Надёжно']} />Настройка таймингов
<GooeyText
texts={['сайты', 'приложения', 'инструменты']}
morphTime={1.5}
cooldownTime={0.5}
/><GooeyText
texts={['сайты', 'приложения', 'инструменты']}
morphTime={1.5}
cooldownTime={0.5}
/>Кастомный стиль и размеры
<GooeyText
texts={['Hero', 'Title']}
fontSize={72}
color="#c4b5fd"
fontFamily="Inter, sans-serif"
fontWeight={800}
width={500}
height={140}
textClass="uppercase tracking-tight"
/><GooeyText
texts={['Hero', 'Title']}
fontSize={72}
color="#c4b5fd"
fontFamily="Inter, sans-serif"
fontWeight={800}
width={500}
height={140}
textClass="uppercase tracking-tight"
/>Пропсы
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
texts | string[] | — | Массив строк для морфинга (обязательный) |
morphTime | number | 1 | Длительность морфинга между словами (с) |
cooldownTime | number | 0.25 | Пауза между морфингами (с) |
fontSize | number | 48 | Размер шрифта в px |
color | string | "black" | Цвет текста |
fontFamily | string | — | Семейство шрифта (наследуется, если не задано) |
fontWeight | string number | "bold" | Насыщенность шрифта |
width | number | 300 | Ширина контейнера в px |
height | number | 100 | Высота контейнера в px |
style | string | "" | Inline-стили корневого элемента |
class | string | "" | Дополнительные классы контейнера |
textClass | string | "" | Дополнительные классы текста |
Особенности
- Полностью на CSS/SVG-фильтрах — не требует внешних библиотек анимаций
- Доступность: скрытый
sr-onlyсписок всех текстов,aria-hiddenдля слоёв - Уважает системную настройку
prefers-reduced-motion - При массиве из одного слова анимация не запускается
