SplitHover
Компонент для создания эффекта “раздвигания” текста при наведении. Текст дублируется, и символы анимируются вертикально один за другим.
Демо
Наведите на меня Наведите на меня Ещё один пример Ещё один пример
<script lang="ts">
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
import { onMount } from "svelte";
import type { Snippet } from "svelte";
import { ensureMotionCoreEase, registerPluginOnce } from "$lib/helpers/gsap";
import { cn } from "$lib/utils/cn";
type ComponentProps = {
children?: Snippet;
class?: string;
hoverTarget?: HTMLElement | null;
[prop: string]: unknown;
};
let {
children,
class: className = "",
hoverTarget = null,
...restProps
}: ComponentProps = $props();
onMount(() => {
registerPluginOnce(SplitText);
ensureMotionCoreEase();
});
let wrapperRef: HTMLSpanElement | undefined;
let originalSpan: HTMLSpanElement | undefined;
let cloneSpan: HTMLSpanElement | undefined;
let originalSplit: SplitText | null = null;
let cloneSplit: SplitText | null = null;
const attachWrapperRef = (node: HTMLSpanElement) => {
wrapperRef = node;
return () => {
if (wrapperRef === node) {
wrapperRef = undefined;
}
};
};
const attachOriginalSpan = (node: HTMLSpanElement) => {
originalSpan = node;
return () => {
if (originalSpan === node) {
originalSpan = undefined;
}
};
};
const attachCloneSpan = (node: HTMLSpanElement) => {
cloneSpan = node;
return () => {
if (cloneSpan === node) {
cloneSpan = undefined;
}
};
};
$effect(() => {
if (typeof window === "undefined") return;
const node = hoverTarget ?? wrapperRef;
if (!node || !originalSpan || !cloneSpan) return;
const currentOriginalSpan = originalSpan;
let timeline: gsap.core.Timeline | null = null;
const ctx = gsap.context(() => {
originalSplit = SplitText.create(currentOriginalSpan, {
type: "chars",
charsClass: "inline-block",
onSplit: (self) => {
const cloneNode = cloneSpan;
if (!cloneNode) return;
if (cloneSplit) cloneSplit.revert();
cloneSplit = SplitText.create(cloneNode, {
type: "chars",
charsClass: "inline-block",
});
gsap.set(self.chars, { yPercent: 0 });
gsap.set(cloneSplit.chars, { yPercent: 100 });
timeline?.kill();
timeline = gsap
.timeline({ paused: true })
.to(
self.chars,
{
yPercent: -100,
stagger: 0.02,
duration: 0.35,
ease: "motion-core-ease",
},
0,
)
.to(
cloneSplit.chars,
{
yPercent: 0,
stagger: 0.02,
duration: 0.35,
ease: "motion-core-ease",
},
0,
);
},
});
}, currentOriginalSpan);
const handleEnter = () => {
timeline?.play();
};
const handleLeave = () => {
timeline?.reverse();
};
node.addEventListener("mouseenter", handleEnter);
node.addEventListener("mouseleave", handleLeave);
return () => {
node.removeEventListener("mouseenter", handleEnter);
node.removeEventListener("mouseleave", handleLeave);
ctx.revert();
originalSplit?.revert();
cloneSplit?.revert();
};
});
</script>
<span
{...restProps}
class={cn(
"font-inherit relative inline-flex overflow-hidden align-baseline leading-none text-inherit",
className,
)}
{@attach attachWrapperRef}
>
<span {@attach attachOriginalSpan}>
{@render children?.()}
</span>
<span
{@attach attachCloneSpan}
class="pointer-events-none absolute inset-0"
aria-hidden="true"
>
{@render children?.()}
</span>
</span><script lang="ts">
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
import { onMount } from "svelte";
import type { Snippet } from "svelte";
import { ensureMotionCoreEase, registerPluginOnce } from "$lib/helpers/gsap";
import { cn } from "$lib/utils/cn";
type ComponentProps = {
children?: Snippet;
class?: string;
hoverTarget?: HTMLElement | null;
[prop: string]: unknown;
};
let {
children,
class: className = "",
hoverTarget = null,
...restProps
}: ComponentProps = $props();
onMount(() => {
registerPluginOnce(SplitText);
ensureMotionCoreEase();
});
let wrapperRef: HTMLSpanElement | undefined;
let originalSpan: HTMLSpanElement | undefined;
let cloneSpan: HTMLSpanElement | undefined;
let originalSplit: SplitText | null = null;
let cloneSplit: SplitText | null = null;
const attachWrapperRef = (node: HTMLSpanElement) => {
wrapperRef = node;
return () => {
if (wrapperRef === node) {
wrapperRef = undefined;
}
};
};
const attachOriginalSpan = (node: HTMLSpanElement) => {
originalSpan = node;
return () => {
if (originalSpan === node) {
originalSpan = undefined;
}
};
};
const attachCloneSpan = (node: HTMLSpanElement) => {
cloneSpan = node;
return () => {
if (cloneSpan === node) {
cloneSpan = undefined;
}
};
};
$effect(() => {
if (typeof window === "undefined") return;
const node = hoverTarget ?? wrapperRef;
if (!node || !originalSpan || !cloneSpan) return;
const currentOriginalSpan = originalSpan;
let timeline: gsap.core.Timeline | null = null;
const ctx = gsap.context(() => {
originalSplit = SplitText.create(currentOriginalSpan, {
type: "chars",
charsClass: "inline-block",
onSplit: (self) => {
const cloneNode = cloneSpan;
if (!cloneNode) return;
if (cloneSplit) cloneSplit.revert();
cloneSplit = SplitText.create(cloneNode, {
type: "chars",
charsClass: "inline-block",
});
gsap.set(self.chars, { yPercent: 0 });
gsap.set(cloneSplit.chars, { yPercent: 100 });
timeline?.kill();
timeline = gsap
.timeline({ paused: true })
.to(
self.chars,
{
yPercent: -100,
stagger: 0.02,
duration: 0.35,
ease: "motion-core-ease",
},
0,
)
.to(
cloneSplit.chars,
{
yPercent: 0,
stagger: 0.02,
duration: 0.35,
ease: "motion-core-ease",
},
0,
);
},
});
}, currentOriginalSpan);
const handleEnter = () => {
timeline?.play();
};
const handleLeave = () => {
timeline?.reverse();
};
node.addEventListener("mouseenter", handleEnter);
node.addEventListener("mouseleave", handleLeave);
return () => {
node.removeEventListener("mouseenter", handleEnter);
node.removeEventListener("mouseleave", handleLeave);
ctx.revert();
originalSplit?.revert();
cloneSplit?.revert();
};
});
</script>
<span
{...restProps}
class={cn(
"font-inherit relative inline-flex overflow-hidden align-baseline leading-none text-inherit",
className,
)}
{@attach attachWrapperRef}
>
<span {@attach attachOriginalSpan}>
{@render children?.()}
</span>
<span
{@attach attachCloneSpan}
class="pointer-events-none absolute inset-0"
aria-hidden="true"
>
{@render children?.()}
</span>
</span>Использование
Базовое использование
<SplitHover>
<span class="text-xl font-bold">Текст при наведении</span>
</SplitHover><SplitHover>
<span class="text-xl font-bold">Текст при наведении</span>
</SplitHover>С внешним триггером
<script>
let hoverTarget: HTMLElement;
</script>
<div bind:this={hoverTarget} class="p-4 bg-background-inset">
<SplitHover hoverTarget={hoverTarget}>
<span>Текст реагирует на наведение на родителя</span>
</SplitHover>
</div><script>
let hoverTarget: HTMLElement;
</script>
<div bind:this={hoverTarget} class="p-4 bg-background-inset">
<SplitHover hoverTarget={hoverTarget}>
<span>Текст реагирует на наведение на родителя</span>
</SplitHover>
</div>Пропсы
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
hoverTarget | HTMLElement null | null | Внешний элемент для триггера hover |
class | string | — | Дополнительные классы |
