Каталог
<canvas class="ribbon"></canvas>
<!--Эффект ленты проектов
https://mt-webdesign.ru/ribbon-mod-->
<style>
canvas.ribbon {
width: 100% !important;
height: 100% !important;
display: block;
cursor: default;
pointer-events: none !important;
}
.ribbon-caption-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 20;
}
.ribbon-caption {
position: fixed;
left: 0;
top: 0;
width: 220px;
text-align: left;
white-space: nowrap;
color: #000; /*цвет шрифта*/
font-family: Inter, sans-serif; /*Меняем семейство шрифтов*/
font-size: 18px; /*размер шрифта*/
font-weight: 500; /*толщина шрифта*/
line-height: 1.1; /*межстрочка*/
opacity: 0;
will-change: transform, opacity;
transform-origin: left center;
backface-visibility: hidden;
pointer-events: none;
}
</style>
<script src="https://matilda-design.ru/library/GSAP.js"></script>
<script src="https://matilda-design.ru/library/ScrollTrigger.js"></script>
<script type="module">
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
document.addEventListener("DOMContentLoaded", () => {
gsap.registerPlugin(ScrollTrigger);
// наполнение проектов (изображение, текст, ссылки) 1: - это первый набор; 2: - это второй набор и тд.
const galleries = {
1: [
{
image: "https://static.tildacdn.com/tild6430-6466-4931-a663-613034316561/ChatGPT_Image_31__20.png",
caption: "Future of Retail",
link: "/project-one"
},
{
image: "https://static.tildacdn.com/tild3736-6631-4239-a636-633165383632/ChatGPT_Image_31__20.png",
caption: "Building a Digital Presence",
link: "/project-two"
},
{
image: "https://static.tildacdn.com/tild3138-3936-4739-a634-366462353862/ChatGPT_Image_31__20.png",
caption: "Reimagining the Brand",
link: "/project-three"
},
{
image: "https://static.tildacdn.com/tild6131-6165-4139-a661-323032643262/ChatGPT_Image_31__20.png",
caption: "A New Online Experience",
link: "/project-four"
},
{
image: "https://static.tildacdn.com/tild3231-3466-4233-b939-636338383437/ChatGPT_Image_31__20.png",
caption: "Designing for Growth",
link: "/project-five"
},
{
image: "https://static.tildacdn.com/tild3130-6531-4235-b834-366261393631/ChatGPT_Image_31__20.png",
caption: "Creating Meaningful Interactions",
link: "/project-six"
},
{
image: "https://static.tildacdn.com/tild3665-3537-4462-a433-343133393935/ChatGPT_Image_31__20.png",
caption: "The Next Chapter",
link: "/project-seven"
},
{
image: "https://static.tildacdn.com/tild6561-6632-4434-b765-306639366534/egor-litvinov-X1Txy1.jpg",
caption: "Shaping the Identity",
link: "/project-eight"
},
{
image: "https://static.tildacdn.com/tild6135-6163-4166-b865-616337653339/ChatGPT_Image_31__20.png",
caption: "From Vision to Launch",
link: "/project-nine"
}
],
2: [
{
image: "https://static.tildacdn.com/tild3966-3537-4235-a336-623866393261/1.jpg",
caption: "Gallery 2 / Project 1",
link: "/gallery-2-project-1"
},
{
image: "https://static.tildacdn.com/tild3736-6631-4239-a636-633165383632/ChatGPT_Image_31__20.png",
caption: "Gallery 2 / Project 2",
link: "/gallery-2-project-2"
},
{
image: "https://static.tildacdn.com/tild3138-3936-4739-a634-366462353862/ChatGPT_Image_31__20.png",
caption: "Gallery 2 / Project 3",
link: "/gallery-2-project-3"
},
{
image: "https://static.tildacdn.com/tild6131-6165-4139-a661-323032643262/ChatGPT_Image_31__20.png",
caption: "Gallery 2 / Project 4",
link: "/gallery-2-project-4"
},
{
image: "https://static.tildacdn.com/tild3231-3466-4233-b939-636338383437/ChatGPT_Image_31__20.png",
caption: "Gallery 2 / Project 5",
link: "/gallery-2-project-5"
},
{
image: "https://static.tildacdn.com/tild3130-6531-4235-b834-366261393631/ChatGPT_Image_31__20.png",
caption: "Gallery 2 / Project 6",
link: "/gallery-2-project-6"
},
{
image: "https://static.tildacdn.com/tild3665-3537-4462-a433-343133393935/ChatGPT_Image_31__20.png",
caption: "Gallery 2 / Project 7",
link: "/gallery-2-project-7"
},
{
image: "https://static.tildacdn.com/tild6561-6632-4434-b765-306639366534/egor-litvinov-X1Txy1.jpg",
caption: "Gallery 2 / Project 8",
link: "/gallery-2-project-8"
},
{
image: "https://static.tildacdn.com/tild6135-6163-4166-b865-616337653339/ChatGPT_Image_31__20.png",
caption: "Gallery 2 / Project 9",
link: "/gallery-2-project-9"
}
]
};
const settings = {
imageWidth: 2,
imageHeight: 1.2,
gap: 0.08,
captionOffsetX: 0,
captionOffsetY: -0.15,
captionFontSize: 18,
captionFontSizeMobile: 12,
captionFadeEdge: 0.96,
ribbonRadius: 30,
baseSpeed: 0.01,
scrollPower: 0.0002,
maxScrollSpeed: 0.5,
ballRadius: 2,
ballDepthOut: 4,
ballDepthIn: 5,
ballSigma: 0.5,
bulgeScrollPower: 0.0055,
bulgeSmooth: 0.3,
bulgeDecay: 0.9,
minItems: 9
};
const captionLayer = document.createElement("div");
captionLayer.className = "ribbon-caption-layer";
document.body.appendChild(captionLayer);
const instances = [];
let globalHoveredItem = null;
let mouseX = 0;
let mouseY = 0;
document.addEventListener("mousemove", event => {
mouseX = event.clientX;
mouseY = event.clientY;
});
document.addEventListener("click", event => {
if (!globalHoveredItem || !globalHoveredItem.link || globalHoveredItem.link === "#") return;
event.preventDefault();
event.stopPropagation();
window.location.href = globalHoveredItem.link;
}, true);
function normalizeProjects(projects, minItems) {
if (!projects || !projects.length) return [];
const result = [];
while (result.length < minItems) {
projects.forEach(project => {
result.push({ ...project });
});
}
return result;
}
function coverTexture(texture, planeAspect) {
const image = texture.image;
const imageAspect = image.width / image.height;
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
if (imageAspect > planeAspect) {
texture.repeat.x = planeAspect / imageAspect;
texture.repeat.y = 1;
texture.offset.x = (1 - texture.repeat.x) / 2;
texture.offset.y = 0;
} else {
texture.repeat.x = 1;
texture.repeat.y = imageAspect / planeAspect;
texture.offset.x = 0;
texture.offset.y = (1 - texture.repeat.y) / 2;
}
texture.needsUpdate = true;
}
function getZoom(elem) {
const zoom = parseFloat(getComputedStyle(elem).zoom || "1");
return isNaN(zoom) ? 1 : zoom;
}
function getEffectiveHeight(elem) {
return elem ? elem.offsetHeight * getZoom(elem) : 0;
}
document.querySelectorAll("canvas.ribbon").forEach(canvas => {
const section = canvas.closest('[class*="uc-ribbon-"]');
if (!section) return;
const match = section.className.match(/uc-ribbon-(\d+)/);
if (!match) return;
const galleryId = match[1];
const projectsData = galleries[galleryId];
if (!projectsData || !projectsData.length) return;
const instance = createRibbonGallery(canvas, section, projectsData);
if (instance) instances.push(instance);
});
function createRibbonGallery(canvas, section, projectsData) {
const projects = normalizeProjects(projectsData, settings.minItems);
if (!projects.length) return null;
const parent = canvas.parentElement;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100);
camera.position.z = 8;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true
});
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const loader = new THREE.TextureLoader();
const group = new THREE.Group();
scene.add(group);
group.position.z = -2.5;
const state = {
speed: settings.baseSpeed,
targetSpeed: settings.baseSpeed,
bulge: 0,
targetBulge: 0
};
const items = [];
const instance = {
canvas,
section,
parent,
scene,
camera,
renderer,
group,
state,
items,
isActive: false,
mouseInsideCanvas: false,
hoveredItem: null,
totalWidth: 0
};
function updateRendererSize() {
const rect = parent.getBoundingClientRect();
if (!rect.width || !rect.height) return;
camera.aspect = rect.width / rect.height;
camera.updateProjectionMatrix();
renderer.setSize(rect.width, rect.height, false);
}
const resizeObserver = new ResizeObserver(() => {
updateRendererSize();
ScrollTrigger.refresh();
});
resizeObserver.observe(parent);
updateRendererSize();
function saveOriginalGeometry(mesh) {
const pos = mesh.geometry.attributes.position;
const original = [];
for (let i = 0; i < pos.count; i++) {
original.push({
x: pos.getX(i),
y: pos.getY(i),
z: pos.getZ(i)
});
}
mesh.userData.original = original;
}
projects.forEach((project, i) => {
const geometry = new THREE.PlaneGeometry(
settings.imageWidth,
settings.imageHeight,
96,
48
);
const material = new THREE.MeshBasicMaterial({
side: THREE.DoubleSide,
transparent: true,
opacity: 0,
depthWrite: true,
depthTest: true
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.x = i * (settings.imageWidth + settings.gap);
mesh.userData.loaded = false;
saveOriginalGeometry(mesh);
group.add(mesh);
const captionEl = document.createElement("div");
captionEl.className = "ribbon-caption";
captionEl.textContent = project.caption || "";
captionLayer.appendChild(captionEl);
items.push({
mesh,
captionEl,
link: project.link || "#",
x: 0,
y: 0,
opacity: 0,
scale: 1,
screenBox: null,
targetX: 0,
targetY: 0,
edge: 1
});
loader.load(project.image, texture => {
texture.colorSpace = THREE.SRGBColorSpace;
coverTexture(texture, settings.imageWidth / settings.imageHeight);
material.map = texture;
material.opacity = 1;
mesh.userData.loaded = true;
material.needsUpdate = true;
});
});
instance.totalWidth = projects.length * (settings.imageWidth + settings.gap);
group.position.x = -instance.totalWidth / 2;
const triggerStart = section.querySelector(".ribbon-top");
const triggerEnd = section.querySelector(".ribbon-bottom");
if (triggerStart && triggerEnd) {
const offsetTop = getEffectiveHeight(triggerStart);
const offsetBottom = getEffectiveHeight(triggerEnd);
const containerHeight = section.offsetHeight;
ScrollTrigger.create({
trigger: section,
start: `top+=${offsetTop} center`,
end: `top+=${containerHeight - offsetBottom} center`,
scrub: 1.2,
markers: false,
onUpdate: self => {
const velocity = self.getVelocity();
state.targetSpeed =
settings.baseSpeed +
gsap.utils.clamp(
-settings.maxScrollSpeed,
settings.maxScrollSpeed,
velocity * settings.scrollPower
);
const direction = velocity >= 0 ? 1 : -1;
state.targetBulge =
direction *
gsap.utils.clamp(
0,
1,
Math.abs(velocity) * settings.bulgeScrollPower
);
},
onLeave: () => {
state.targetSpeed = settings.baseSpeed;
state.targetBulge = 0;
},
onLeaveBack: () => {
state.targetSpeed = settings.baseSpeed;
state.targetBulge = 0;
}
});
}
ScrollTrigger.create({
trigger: section,
start: "top bottom",
end: "bottom top",
onEnter: () => instance.isActive = true,
onEnterBack: () => instance.isActive = true,
onLeave: () => {
instance.isActive = false;
hideInstanceCaptions(instance);
},
onLeaveBack: () => {
instance.isActive = false;
hideInstanceCaptions(instance);
}
});
return instance;
}
function hideInstanceCaptions(instance) {
instance.items.forEach(item => {
item.opacity = 0;
item.captionEl.style.opacity = "0";
});
}
function applyRibbonDistortion(instance, mesh) {
const pos = mesh.geometry.attributes.position;
const original = mesh.userData.original;
if (!original) return;
const group = instance.group;
const state = instance.state;
const meshWorldX = mesh.position.x + group.position.x;
const sigma = settings.ballRadius * settings.ballSigma;
for (let i = 0; i < pos.count; i++) {
const o = original[i];
const worldX = meshWorldX + o.x;
const dist = Math.abs(worldX);
const angle = worldX / settings.ribbonRadius;
const xRibbon = Math.sin(angle) * settings.ribbonRadius;
const zRibbon = (Math.cos(angle) - 1) * settings.ribbonRadius;
const mask = Math.exp(-(dist * dist) / (2 * sigma * sigma));
const depth = state.bulge >= 0
? settings.ballDepthOut
: settings.ballDepthIn;
const zBulge = mask * state.bulge * depth;
pos.setX(i, xRibbon - meshWorldX);
pos.setY(i, o.y);
pos.setZ(i, zRibbon + zBulge);
}
pos.needsUpdate = true;
mesh.geometry.computeVertexNormals();
}
function getRibbonPoint(instance, mesh, offsetX, offsetY) {
const group = instance.group;
const state = instance.state;
const meshWorldX = mesh.position.x + group.position.x + offsetX;
const sigma = settings.ballRadius * settings.ballSigma;
const worldX = meshWorldX;
const dist = Math.abs(worldX);
const angle = worldX / settings.ribbonRadius;
const xRibbon = Math.sin(angle) * settings.ribbonRadius;
const zRibbon = (Math.cos(angle) - 1) * settings.ribbonRadius;
const mask = Math.exp(-(dist * dist) / (2 * sigma * sigma));
const depth = state.bulge >= 0
? settings.ballDepthOut
: settings.ballDepthIn;
const zBulge = mask * state.bulge * depth;
return new THREE.Vector3(
xRibbon,
offsetY,
group.position.z + zRibbon + zBulge
);
}
function updateInfiniteLine(instance) {
const halfWidth = instance.totalWidth / 2;
instance.group.children.forEach(mesh => {
const worldX = mesh.position.x + instance.group.position.x;
if (worldX < -halfWidth) {
mesh.position.x += instance.totalWidth;
}
if (worldX > halfWidth) {
mesh.position.x -= instance.totalWidth;
}
});
}
function updateScreenBoxesAndCaptionTargets(instance) {
const rect = instance.canvas.getBoundingClientRect();
instance.items.forEach(item => {
const mesh = item.mesh;
if (!mesh.userData.loaded || !mesh.visible) {
item.screenBox = null;
return;
}
const topLeft = getRibbonPoint(instance, mesh, -settings.imageWidth / 2, settings.imageHeight / 2);
const topRight = getRibbonPoint(instance, mesh, settings.imageWidth / 2, settings.imageHeight / 2);
const bottomLeft = getRibbonPoint(instance, mesh, -settings.imageWidth / 2, -settings.imageHeight / 2);
const bottomRight = getRibbonPoint(instance, mesh, settings.imageWidth / 2, -settings.imageHeight / 2);
[topLeft, topRight, bottomLeft, bottomRight].forEach(p => p.project(instance.camera));
const xs = [topLeft.x, topRight.x, bottomLeft.x, bottomRight.x].map(
x => rect.left + (x * 0.5 + 0.5) * rect.width
);
const ys = [topLeft.y, topRight.y, bottomLeft.y, bottomRight.y].map(
y => rect.top + (-y * 0.5 + 0.5) * rect.height
);
item.screenBox = {
left: Math.min(...xs),
right: Math.max(...xs),
top: Math.min(...ys),
bottom: Math.max(...ys)
};
const captionPoint = getRibbonPoint(
instance,
mesh,
-settings.imageWidth / 2 + settings.captionOffsetX,
-settings.imageHeight / 2 + settings.captionOffsetY
);
captionPoint.project(instance.camera);
item.targetX = rect.left + (captionPoint.x * 0.5 + 0.5) * rect.width;
item.targetY = rect.top + (-captionPoint.y * 0.5 + 0.5) * rect.height;
item.edge = Math.abs(captionPoint.x);
});
}
function updateScreenHover(instance) {
instance.hoveredItem = null;
const rect = instance.canvas.getBoundingClientRect();
instance.mouseInsideCanvas =
mouseX >= rect.left &&
mouseX <= rect.right &&
mouseY >= rect.top &&
mouseY <= rect.bottom;
if (!instance.mouseInsideCanvas) return;
for (let i = instance.items.length - 1; i >= 0; i--) {
const item = instance.items[i];
const box = item.screenBox;
if (!box) continue;
if (
mouseX >= box.left &&
mouseX <= box.right &&
mouseY >= box.top &&
mouseY <= box.bottom
) {
instance.hoveredItem = item;
break;
}
}
}
function updateCaptions(instance) {
const isMobile = window.innerWidth <= 480;
const captionFontSize = isMobile
? settings.captionFontSizeMobile
: settings.captionFontSize;
instance.items.forEach(item => {
const mesh = item.mesh;
const captionEl = item.captionEl;
captionEl.style.fontSize = `${captionFontSize}px`;
if (!mesh.userData.loaded || !mesh.visible || !item.screenBox) {
item.opacity = 0;
captionEl.style.opacity = "0";
return;
}
if (isMobile) {
item.x = item.targetX;
item.y = item.targetY;
item.opacity = 1;
item.scale = 1;
const mobileX = Math.round(item.x * 100) / 100;
const mobileY = Math.round(item.y * 100) / 100;
captionEl.style.opacity = "1";
captionEl.style.transform =
`translate3d(${mobileX}px, ${mobileY}px, 0) translate(0, -50%)`;
return;
}
const isHovered = instance.hoveredItem === item;
const targetOpacity = isHovered
? THREE.MathUtils.clamp(
(settings.captionFadeEdge - item.edge) / 0.18,
0,
1
)
: 0;
const targetScale = THREE.MathUtils.clamp(
1 - item.edge * 0.18,
0.78,
1
);
item.x += (item.targetX - item.x) * 0.45;
item.y += (item.targetY - item.y) * 0.45;
item.opacity += (targetOpacity - item.opacity) * 0.25;
item.scale += (targetScale - item.scale) * 0.25;
const smoothX = Math.round(item.x * 100) / 100;
const smoothY = Math.round(item.y * 100) / 100;
const smoothScale = Math.round(item.scale * 1000) / 1000;
captionEl.style.opacity = item.opacity.toFixed(3);
captionEl.style.transform =
`translate3d(${smoothX}px, ${smoothY}px, 0) translate(0, -50%) scale(${smoothScale})`;
});
}
function renderInstance(instance) {
if (!instance.isActive) return;
const state = instance.state;
state.speed += (state.targetSpeed - state.speed) * 0.08;
state.targetSpeed += (settings.baseSpeed - state.targetSpeed) * 0.06;
state.bulge += (state.targetBulge - state.bulge) * settings.bulgeSmooth;
state.targetBulge *= settings.bulgeDecay;
instance.group.position.x -= state.speed;
updateInfiniteLine(instance);
instance.group.children.forEach(mesh => {
applyRibbonDistortion(instance, mesh);
mesh.visible = mesh.userData.loaded;
});
updateScreenBoxesAndCaptionTargets(instance);
updateScreenHover(instance);
updateCaptions(instance);
instance.renderer.render(instance.scene, instance.camera);
}
function animate() {
requestAnimationFrame(animate);
globalHoveredItem = null;
instances.forEach(instance => {
renderInstance(instance);
if (instance.hoveredItem) {
globalHoveredItem = instance.hoveredItem;
}
});
document.body.style.cursor = globalHoveredItem ? "pointer" : "default";
}
animate();
});
</script>