Диагональный текст
Компонент для акцентного текста с проходящей градиентной полосой. Поддерживает одиночную строку, массив строк, задержку, длительность, циклическое воспроизведение и callback завершения прохода.
Превью
Мы создаём сайты сайты
<script lang="ts">
type Props = {
/** Text or texts to sweep through. */
text: string | string[];
/** Colors used by the moving highlight. */
sweepColors?: string[];
/** Base text color. */
baseColor?: string;
/** Sweep duration in milliseconds. */
duration?: number;
/** Delay before the sweep starts, in milliseconds. */
delay?: number;
/** Repeat the sweep (and advance through text[]). */
loop?: boolean;
/** Delay between completed sweeps, in milliseconds. */
loopDelay?: number;
/** Highlight width as a fraction of the text width. */
bandRatio?: number;
/** Start the animation automatically. */
autoPlay?: boolean;
/** CSS class applied to the root element. */
class?: string;
/** CSS class applied to the text. */
textClass?: string;
/** Inline styles for the root element. */
style?: string;
/** Inline styles for the text. */
textStyle?: string;
onSweepEnd?: (index: number) => void;
};
let {
text,
sweepColors = ["#ffffff", "#ffffff", "#ffffff"],
baseColor = "currentColor",
duration = 1200,
delay = 0,
loop = false,
loopDelay = 0,
bandRatio = 0.22,
autoPlay = true,
class: className = "",
textClass = "",
style = "",
textStyle = "",
onSweepEnd,
}: Props = $props();
let index = $state(0);
let playing = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
let animationId = $state(0);
const texts = $derived(Array.isArray(text) ? text : [text]);
const currentText = $derived(texts[index] ?? "");
const isMulti = $derived(texts.length > 1);
const gradient = $derived.by(() => {
const colors = sweepColors.length > 0 ? sweepColors : [baseColor];
const stops = colors.length === 1
? `${baseColor} 0%, ${colors[0]} 50%, ${baseColor} 100%`
: [baseColor, ...colors, baseColor]
.map((color, i, all) => `${color} ${(i / (all.length - 1)) * 100}%`)
.join(", ");
return `linear-gradient(90deg, ${stops})`;
});
function clearTimer() {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
}
function finishSweep(finishedIndex: number) {
playing = false;
onSweepEnd?.(finishedIndex);
if (!loop) return;
timer = setTimeout(() => {
if (isMulti) index = (index + 1) % texts.length;
animationId += 1;
playing = true;
}, loopDelay);
}
function play() {
clearTimer();
if (!autoPlay || !texts.length) return;
playing = true;
animationId += 1;
}
$effect(() => {
if (texts.length === 0) {
index = 0;
playing = false;
clearTimer();
return;
}
index = 0;
if (autoPlay) play();
return clearTimer;
});
$effect(() => {
if (!playing) return;
const id = animationId;
const timeout = setTimeout(() => {
if (id === animationId) finishSweep(index);
}, Math.max(0, delay) + Math.max(0, duration));
return () => clearTimeout(timeout);
});
</script>
<span
class={`dia-text ${className}`}
style={`${style} --dia-base:${baseColor}; --dia-gradient:${gradient}; --dia-duration:${Math.max(0, duration)}ms; --dia-delay:${Math.max(0, delay)}ms; --dia-band:${Math.max(0.05, bandRatio) * 100}%;`}
>
<span class="dia-text__sizer" aria-hidden="true">{currentText || "\u00a0"}</span>
{#key `${index}:${animationId}`}
<span
class={`dia-text__label ${textClass} ${playing ? "dia-text__label--playing" : ""}`}
style={textStyle}
aria-label={currentText}
>
{currentText}
</span>
{/key}
</span>
<style>
.dia-text {
position: relative;
display: inline-grid;
vertical-align: middle;
line-height: inherit;
color: var(--dia-base);
}
.dia-text__sizer,
.dia-text__label {
grid-area: 1 / 1;
white-space: nowrap;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
.dia-text__sizer {
visibility: hidden;
pointer-events: none;
user-select: none;
}
.dia-text__label {
background: var(--dia-base);
background-image: none;
background-repeat: no-repeat;
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: currentColor;
color: var(--dia-base);
}
.dia-text__label--playing {
background-image: var(--dia-gradient);
-webkit-text-fill-color: transparent;
animation: dia-text-sweep var(--dia-duration) ease-in-out var(--dia-delay) both;
}
@keyframes dia-text-sweep {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dia-text__label--playing {
animation: none;
background-image: none;
-webkit-text-fill-color: currentColor;
}
}
</style>
<script lang="ts">
type Props = {
/** Text or texts to sweep through. */
text: string | string[];
/** Colors used by the moving highlight. */
sweepColors?: string[];
/** Base text color. */
baseColor?: string;
/** Sweep duration in milliseconds. */
duration?: number;
/** Delay before the sweep starts, in milliseconds. */
delay?: number;
/** Repeat the sweep (and advance through text[]). */
loop?: boolean;
/** Delay between completed sweeps, in milliseconds. */
loopDelay?: number;
/** Highlight width as a fraction of the text width. */
bandRatio?: number;
/** Start the animation automatically. */
autoPlay?: boolean;
/** CSS class applied to the root element. */
class?: string;
/** CSS class applied to the text. */
textClass?: string;
/** Inline styles for the root element. */
style?: string;
/** Inline styles for the text. */
textStyle?: string;
onSweepEnd?: (index: number) => void;
};
let {
text,
sweepColors = ["#ffffff", "#ffffff", "#ffffff"],
baseColor = "currentColor",
duration = 1200,
delay = 0,
loop = false,
loopDelay = 0,
bandRatio = 0.22,
autoPlay = true,
class: className = "",
textClass = "",
style = "",
textStyle = "",
onSweepEnd,
}: Props = $props();
let index = $state(0);
let playing = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
let animationId = $state(0);
const texts = $derived(Array.isArray(text) ? text : [text]);
const currentText = $derived(texts[index] ?? "");
const isMulti = $derived(texts.length > 1);
const gradient = $derived.by(() => {
const colors = sweepColors.length > 0 ? sweepColors : [baseColor];
const stops = colors.length === 1
? `${baseColor} 0%, ${colors[0]} 50%, ${baseColor} 100%`
: [baseColor, ...colors, baseColor]
.map((color, i, all) => `${color} ${(i / (all.length - 1)) * 100}%`)
.join(", ");
return `linear-gradient(90deg, ${stops})`;
});
function clearTimer() {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
}
function finishSweep(finishedIndex: number) {
playing = false;
onSweepEnd?.(finishedIndex);
if (!loop) return;
timer = setTimeout(() => {
if (isMulti) index = (index + 1) % texts.length;
animationId += 1;
playing = true;
}, loopDelay);
}
function play() {
clearTimer();
if (!autoPlay || !texts.length) return;
playing = true;
animationId += 1;
}
$effect(() => {
if (texts.length === 0) {
index = 0;
playing = false;
clearTimer();
return;
}
index = 0;
if (autoPlay) play();
return clearTimer;
});
$effect(() => {
if (!playing) return;
const id = animationId;
const timeout = setTimeout(() => {
if (id === animationId) finishSweep(index);
}, Math.max(0, delay) + Math.max(0, duration));
return () => clearTimeout(timeout);
});
</script>
<span
class={`dia-text ${className}`}
style={`${style} --dia-base:${baseColor}; --dia-gradient:${gradient}; --dia-duration:${Math.max(0, duration)}ms; --dia-delay:${Math.max(0, delay)}ms; --dia-band:${Math.max(0.05, bandRatio) * 100}%;`}
>
<span class="dia-text__sizer" aria-hidden="true">{currentText || "\u00a0"}</span>
{#key `${index}:${animationId}`}
<span
class={`dia-text__label ${textClass} ${playing ? "dia-text__label--playing" : ""}`}
style={textStyle}
aria-label={currentText}
>
{currentText}
</span>
{/key}
</span>
<style>
.dia-text {
position: relative;
display: inline-grid;
vertical-align: middle;
line-height: inherit;
color: var(--dia-base);
}
.dia-text__sizer,
.dia-text__label {
grid-area: 1 / 1;
white-space: nowrap;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
.dia-text__sizer {
visibility: hidden;
pointer-events: none;
user-select: none;
}
.dia-text__label {
background: var(--dia-base);
background-image: none;
background-repeat: no-repeat;
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: currentColor;
color: var(--dia-base);
}
.dia-text__label--playing {
background-image: var(--dia-gradient);
-webkit-text-fill-color: transparent;
animation: dia-text-sweep var(--dia-duration) ease-in-out var(--dia-delay) both;
}
@keyframes dia-text-sweep {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dia-text__label--playing {
animation: none;
background-image: none;
-webkit-text-fill-color: currentColor;
}
}
</style>
Использование
<script lang="ts">
import DiaText from '$lib/components/ui-registry/DiaText.svelte';
</script>
<p>
Мы создаём <DiaText text="цифровые продукты" />
</p><script lang="ts">
import DiaText from '$lib/components/ui-registry/DiaText.svelte';
</script>
<p>
Мы создаём <DiaText text="цифровые продукты" />
</p>Циклическая смена
<DiaText
text={['сайты', 'приложения', 'инструменты']}
loop
duration={1400}
delay={200}
loopDelay={800}
sweepColors={['#ffffff', '#8b5cf6', '#ffffff']}
baseColor="currentColor"
/><DiaText
text={['сайты', 'приложения', 'инструменты']}
loop
duration={1400}
delay={200}
loopDelay={800}
sweepColors={['#ffffff', '#8b5cf6', '#ffffff']}
baseColor="currentColor"
/>Пропсы
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
text | string string[] | — | Текст или массив текстов |
sweepColors | string[] | светлые оттенки | Цвета проходящей полосы |
baseColor | string | currentColor | Базовый цвет текста |
duration | number | 1200 | Длительность прохода в мс |
delay | number | 0 | Задержка перед проходом в мс |
loop | boolean | false | Повторять анимацию и переключать строки |
loopDelay | number | 0 | Пауза между проходами в мс |
bandRatio | number | 0.22 | Относительная ширина градиентной полосы |
autoPlay | boolean | true | Запускать анимацию автоматически |
class | string | '' | Класс корневого элемента |
textClass | string | '' | Класс текста |
style | string | '' | Inline-стили корневого элемента |
textStyle | string | '' | Inline-стили текста |
onSweepEnd | (index: number) => void | — | Callback после завершения прохода |
Особенности
- Не требует React Native, Reanimated или MaskedView — анимация работает через CSS и Svelte.
- Поддерживает
prefers-reduced-motion. - Подходит для SSR в SvelteKit: таймеры запускаются только в браузере через
$effect.
