Skip to main content

实用案例

拖拽svg

svg_拖拽连线球

// 导入新版 GSAP 模块
import { gsap } from "gsap";
import { Draggable } from "gsap/Draggable";

gsap.registerPlugin(Draggable);

// 配置类型定义(扁平结构)
interface WidgetConfig {
particleCount: number; // 粒子数量
minParticleSize: number; // 最小粒子尺寸
maxParticleSize: number; // 最大粒子尺寸
particleColor: string; // 粒子颜色
lineColor: string; // 连线颜色
baseColor: string; // 按钮底层颜色
mainColor: string; // 按钮主颜色
highlightColor: string; // 按钮高亮颜色
textColor: string; // 按钮文字颜色
buttonText: string; // 按钮显示文字
}

// 默认配置(扁平化)
const DEFAULT_CONFIG: WidgetConfig = {
particleCount: 15,
minParticleSize: 7,
maxParticleSize: 15,
particleColor: "silver",
lineColor: "rgba(0,0,0,0.8)",
baseColor: "rgba(0,0,0,0.2)",
mainColor: "#d50027",
highlightColor: "#ff002f",
textColor: "white",
buttonText: "LET ME GO",
};

class InteractiveWidget {
private config: WidgetConfig;
private container: HTMLElement;
private svgElement: SVGSVGElement;
private centerButton: SVGGElement;
private positionsX: number[] = [];
private positionsY: number[] = [];
private centerX: number = 0;
private centerY: number = 0;
private draggableInstance?: Draggable[]; // 新增拖拽实例引用

constructor(container: HTMLElement, userConfig?: Partial<WidgetConfig>) {
// 合并配置
this.config = { ...DEFAULT_CONFIG, ...userConfig };
this.container = container;

// 初始化 SVG
this.initSVG();
this.createCenterButton();
this.createParticles();
this.setupDrag();

// 新增初始化连线更新
this.updateConnections();
}

// 初始化 SVG 画布
private initSVG(): void {
const { offsetWidth: width, offsetHeight: height } = this.container;

this.svgElement = document.createElementNS("http://www.w3.org/2000/svg", "svg");
this.svgElement.style.position = "absolute";
// this.svgElement.style.left = "0";
// this.svgElement.style.top = "0";
this.svgElement.setAttribute("width", `${width}px`);
this.svgElement.setAttribute("height", `${height}px`);
this.svgElement.setAttribute("viewBox", `0 0 ${width} ${height}`);

this.container.appendChild(this.svgElement);
}

// 创建中心按钮
private createCenterButton(): void {
const { baseColor, mainColor, highlightColor, textColor, buttonText } = this.config;

this.centerButton = document.createElementNS("http://www.w3.org/2000/svg", "g");

// 创建圆形元素
const createCircle = (cy: number, r: number, fill: string): SVGCircleElement => {
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cy", cy.toString());
circle.setAttribute("r", r.toString());
circle.setAttribute("fill", fill);
return circle;
};

this.centerButton.appendChild(createCircle(20, 73, baseColor));
this.centerButton.appendChild(createCircle(0, 80, mainColor));
this.centerButton.appendChild(createCircle(-5, 75, highlightColor));

// 创建文本元素
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.textContent = buttonText;
text.setAttribute("text-anchor", "middle");
text.setAttribute("font-family", "Verdana");
text.setAttribute("alignment-baseline", "middle");
text.setAttribute("font-size", "20");
text.setAttribute("fill", textColor);

this.centerButton.appendChild(text);
this.svgElement.appendChild(this.centerButton);
}

// 创建粒子系统
private createParticles(): void {
const { particleCount, minParticleSize, maxParticleSize, particleColor, lineColor } = this.config;

for (let i = 0; i < particleCount; i++) {
const size = this.getRandomInt(minParticleSize, maxParticleSize);
const [x, y] = this.generatePosition();

// 创建粒子
const particle = this.createSVGElement("circle", {
r: size.toString(),
cx: x.toString(),
cy: y.toString(),
fill: particleColor,
opacity: "0.7",
stroke: "gray",
"stroke-width": "2",
});

// 创建连线
const line = this.createSVGElement("line", {
x1: x.toString(),
y1: y.toString(),
// x2: x.toString(),
// y2: y.toString(),
x2: this.centerX.toString(),
y2: this.centerY.toString(),
"stroke-width": (size / 2).toString(),
"stroke-linecap": "round",
stroke: lineColor,
});

this.svgElement.insertBefore(particle, this.centerButton);
this.svgElement.insertBefore(line, this.centerButton);
this.positionsX.push(x);
this.positionsY.push(y);
}

// 计算中心点
// this.centerX = this.calculateCenter(this.positionsX);
// this.centerY = this.calculateCenter(this.positionsY);
// gsap.set(this.centerButton, { x: this.centerX, y: this.centerY });

this.centerX = this.calculateCenter(this.positionsX);
this.centerY = this.calculateCenter(this.positionsY);
gsap.set(this.centerButton, {
x: this.centerX,
y: this.centerY,
onComplete: () => this.updateConnections(),
});
}

// 设置拖拽交互
private setupDrag(): void {
// 将元素包装为数组
this.draggableInstance = Draggable.create([this.centerButton], {
// [!code focus]
type: "x,y",
onDrag: () => this.updateConnections(),
onRelease: () => {
gsap.to(this.centerButton, {
duration: 1,
x: this.centerX,
y: this.centerY,
ease: "elastic.out(1, 0.15)",
onUpdate: () => this.updateConnections(),
});
},
});
}

// 更新连线
private updateConnections(): void {
const transform = (this.centerButton as any)._gsap;
gsap.set("line", {
attr: {
x2: transform?.x || this.centerX,
y2: transform?.y || this.centerY,
},
});
}

// 工具方法
private getRandomInt(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min));
}

private generatePosition(): [number, number] {
const { offsetWidth: w, offsetHeight: h } = this.container;
return [this.getRandomInt(10, w - 10), this.getRandomInt(10, h - 10)];
}

private calculateCenter(arr: number[]): number {
const min = Math.min(...arr);
return min + (Math.max(...arr) - min) / 2;
}

private createSVGElement<T extends keyof SVGElementTagNameMap>(tag: T, attrs: Record<string, string>): SVGElement {
const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
Object.entries(attrs).forEach(([key, value]) => {
element.setAttribute(key, value);
});
return element;
}

public destroy(): void {
// 1. 清除所有GSAP动画
gsap.killTweensOf(this.centerButton);

// 2. 销毁拖拽实例
if (this.draggableInstance) {
this.draggableInstance.forEach((instance) => instance.kill());
this.draggableInstance = undefined;
}

// 3. 移除DOM元素
if (this.svgElement && this.svgElement.parentNode === this.container) {
this.container.removeChild(this.svgElement);
}

// 4. 清除引用帮助GC
this.svgElement.remove();
(this as any).svgElement = null;
(this as any).centerButton = null;
(this as any).container = null;
}
}

// 类型扩展声明
declare global {
interface Window {
_gsap: any;
}
}

export default InteractiveWidget;

左右无限滚动

包装成函数

const useInfiniteScroll = (
containerRef: React.RefObject<HTMLElement>,
{ direction = "left", loop = false, speed = 80 }: { direction?: "left" | "right"; loop?: boolean; speed?: number }
) => {
useEffect(() => {
if (!containerRef.current) return;
if (!loop) return;

const content = containerRef.current.children[0] as HTMLElement;
const clone = content.cloneNode(true);
containerRef.current.appendChild(clone);

const ctx = gsap.context(() => {
const tl = gsap.timeline({ repeat: -1 }).to([content, clone], {
xPercent: direction === "left" ? -100 : 100,
ease: "none",
duration: speed,
modifiers: { xPercent: gsap.utils.wrap(-100, 0) }, // 无缝循环核心逻辑
});
}, containerRef);

return () => ctx.revert();
}, [direction, loop, speed]);
};

// 组件内部
useInfiniteScroll(elementRef, {speed:80})

写入到组件中

const LogoIconRow: React.FC<{ direction?: "left" | "right"; loop?: boolean; speed?: number }> = ({
direction = "left",
loop = false,
speed = 80,
}) => {
const maxWidth = 120;
const height = 60;
const containerRef = useRef<HTMLDivElement>(null);

// 初始化滚动
useEffect(() => {
if (!containerRef.current) return;
if (!loop) return;

const content = containerRef.current.children[0] as HTMLElement;
const clone = content.cloneNode(true);
containerRef.current.appendChild(clone);

const ctx = gsap.context(() => {
const tl = gsap.timeline({ repeat: -1 }).to([content, clone], {
xPercent: direction === "left" ? -100 : 100,
ease: "none",
duration: speed,
modifiers: { xPercent: gsap.utils.wrap(-100, 0) }, // 无缝循环核心逻辑
});
}, containerRef);

return () => ctx.revert();
}, [direction, loop, speed]);

return (
<div ref={containerRef} className="flex items-center max-w-[1550px] w-screen h-[120px] mx-auto overflow-x-hidden">
<div
className={["inline-flex items-center justify-center gap-8 p-4 flex-nowrap", direction == "left" ? "flex-row" : "flex-row-reverse"].join(" ")}
>
{svgList.map((svg, i) => (
<img src={svg} style={{ height, maxWidth }} alt={svg} key={i}></img>
))}
</div>
</div>
);
};

元素跟随


层叠卡片效果

https://codepen.io/hexagoncircle/pen/LYovXPJ gsap-SplitText_卡片层叠

<<ul class="cards">
<li class="card"></li>
<li class="card"></li>
<li class="card"></li>
<li class="card"></li>
<li class="card"></li>
<li class="card"></li>
<li class="card"></li>
<li class="card"></li>
<li class="card"></li>
<li class="card"></li>
<li class="card"></li>
<li class="card"></li>
</ul>

<style>
* {
box-sizing: border-box;
}

html,
body {
height: 100%;
}

body {
display: grid;
place-items: center;
overflow: hidden;
font-family: system-ui, sans-serif;
}

.cards {
display: grid;
grid-template-areas: "cards";
}

.card {
grid-area: cards;
display: flex;
width: 23vmin;
aspect-ratio: 2.5/3.5;
border-radius: 2.5vmin;
border: 0.2vmin solid hsl(0 0% 0% / 0.25);
background-color: #f0f0f0;
/* Pattern from heropatterns.com */
background-image: url("data:image/svg+xml,%3Csvg width='180' height='180' viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M81.28 88H68.413l19.298 19.298L81.28 88zm2.107 0h13.226L90 107.838 83.387 88zm15.334 0h12.866l-19.298 19.298L98.72 88zm-32.927-2.207L73.586 78h32.827l.5.5 7.294 7.293L115.414 87l-24.707 24.707-.707.707L64.586 87l1.207-1.207zm2.62.207L74 80.414 79.586 86H68.414zm16 0L90 80.414 95.586 86H84.414zm16 0L106 80.414 111.586 86h-11.172zm-8-6h11.173L98 85.586 92.414 80zM82 85.586L87.586 80H76.414L82 85.586zM17.414 0L.707 16.707 0 17.414V0h17.414zM4.28 0L0 12.838V0h4.28zm10.306 0L2.288 12.298 6.388 0h8.198zM180 17.414L162.586 0H180v17.414zM165.414 0l12.298 12.298L173.612 0h-8.198zM180 12.838L175.72 0H180v12.838zM0 163h16.413l.5.5 7.294 7.293L25.414 172l-8 8H0v-17zm0 10h6.613l-2.334 7H0v-7zm14.586 7l7-7H8.72l-2.333 7h8.2zM0 165.414L5.586 171H0v-5.586zM10.414 171L16 165.414 21.586 171H10.414zm-8-6h11.172L8 170.586 2.414 165zM180 163h-16.413l-7.794 7.793-1.207 1.207 8 8H180v-17zm-14.586 17l-7-7h12.865l2.333 7h-8.2zM180 173h-6.613l2.334 7H180v-7zm-21.586-2l5.586-5.586 5.586 5.586h-11.172zM180 165.414L174.414 171H180v-5.586zm-8 5.172l5.586-5.586h-11.172l5.586 5.586zM152.933 25.653l1.414 1.414-33.94 33.942-1.416-1.416 33.943-33.94zm1.414 127.28l-1.414 1.414-33.942-33.94 1.416-1.416 33.94 33.943zm-127.28 1.414l-1.414-1.414 33.94-33.942 1.416 1.416-33.943 33.94zm-1.414-127.28l1.414-1.414 33.942 33.94-1.416 1.416-33.94-33.943zM0 85c2.21 0 4 1.79 4 4s-1.79 4-4 4v-8zm180 0c-2.21 0-4 1.79-4 4s1.79 4 4 4v-8zM94 0c0 2.21-1.79 4-4 4s-4-1.79-4-4h8zm0 180c0-2.21-1.79-4-4-4s-4 1.79-4 4h8z' fill='%23000000' fill-opacity='0.2' fill-rule='evenodd'/%3E%3C/svg%3E");
background-size: 50%;
will-change: transform;
}

</style>
let cards = document.querySelectorAll(".card");
let cardsMidIndex = Math.floor(cards.length / 2);
let yOffset = 60;
let scaleOffset = 0.02;
let duration = 0.8;
let scaleDuration = duration / 3;
let tl = gsap.timeline({ repeat: -1, yoyoEase: true });

function driftIn() {
return gsap.timeline().from(".cards", {
yPercent: -yOffset / 3,
duration,
ease: "power2.inOut",
yoyoEase: true
});
}

function driftOut() {
return gsap.timeline().to(".cards", {
yPercent: yOffset / 3,
duration,
ease: "power2.inOut",
yoyoEase: true
});
}

function scaleCards() {
return gsap
.timeline()
.to(".card", {
scale: (i) => {
if (i <= cardsMidIndex) {
return 1 - i * scaleOffset;
} else {
return 1 - (cards.length - 1 - i) * scaleOffset;
}
},
delay: duration / 3,
duration: scaleDuration,
ease: "expo.inOut",
yoyoEase: true
})
.to(".card", { scale: 1, duration: scaleDuration });
}

function shuffleCards() {
return gsap
.timeline()
.set(".card", {
y: (i) => -i * 0.5
})
.fromTo(
".card",
{
rotate: 45,
yPercent: -yOffset
},
{
duration,
rotate: 65,
yPercent: yOffset,
stagger: duration * 0.03,
ease: "expo.inOut",
yoyoEase: true
}
);
}

function shuffleDeck() {
tl.add(driftIn())
.add(shuffleCards(), "<")
.add(scaleCards(), "<")
.add(driftOut(), "<55%");
}

shuffleDeck();

文字旋转滚动切换

codepen:https://codepen.io/loic-e/pen/xxgggqX

gsap-SplitText_旋转文字

<h1 id="sayHi">Bonjour</h1>

<style>
#sayHi {
position: absolute;
display: flex;
height: 5.4rem;
align-items: center;
font-size: 4.5rem;
top: 50%;
left: 50%;
padding: 0.6rem;
transform: translate(-50%, -50%);
}

.word {
margin-right: 1rem;
white-space: nowrap;
}

.word:last-child {
margin-right: 0;
}
</style>

<scripts></scripts>

class WordTransition {
currentIndex = 0;
constructor(target, words) {
this.target =
typeof target === "string" ? document.querySelector(target) : target;
words.unshift(this.target.textContent);
this.nextWord = this.nextWord.bind(this);
this.words = words;
this.animate();
}

nextWord() {
this.currentIndex =
this.currentIndex >= this.words.length - 1 ? 0 : this.currentIndex + 1;
this.target.innerHTML = this.words[this.currentIndex];
this.animate();
}

animate() {
let splitted = new SplitText(this.target, {
type: "words,chars",
wordsClass: "word"
});
let chars = splitted.chars;
let textanimation = gsap.timeline({
repeat: 1,
yoyo: true,
repeatDelay: 1
});
textanimation.fromTo(
chars,
{
opacity: 0,
rotateX: 360,
rotateY: 90
},
{
duration: 1,
opacity: 1,
scale: 1,
y: 0,
// ease: 'back',
rotateX: 0,
rotateY: 0,
stagger: 0.05,
onReverseComplete: this.nextWord
}
);
}
}

document.addEventListener("DOMContentLoaded", function () {
//gsap.registerPlugin(ScrambleTextPlugin, SplitText);
new WordTransition("#sayHi", [
"Hello",
"Hola",
"Zdravstvuyte",
"Nǐn hǎo",
"Salve",
"Konnichiwa",
"Guten Tag",
"Olá",
"Anyoung haseyo",
"Asalaam alaikum",
"Goddag",
"Shikamoo",
"Goedendag",
"Yassas",
"Dzień dobry",
"Selamat siang",
"Namaste",
"Merhaba",
"Shalom"
]);
});

边界文字环绕

项目地址:https://codepen.io/hexagoncircle/pen/xxwBLMy

image-20250521104634955