Frontend / Motion / Код
Скролл, 3D и ребристое стекло: код эффектов App3D
Как устроены эффекты опубликованного лендинга: от обратимой мозаики и SVG-«ластика» до PointLight и стекла, которое исчезает при наведении. Показываю реальные фрагменты модулей и даю запускаемый архив.

Сначала разделите владельцев состояния
В App3D один и тот же кадр участвует в раскрытии, параллаксе и оптическом эффекте. Если несколько обработчиков меняют один transform, результат зависит от порядка обновлений. Поэтому у фотографии есть отдельные вложенные слои: внешний контейнер обрезает изображение, один внутренний слой отвечает за параллакс, другой содержит сам кадр и Canvas. Маска раскрытия лежит поверх них.
Основные исполняемые исходники — landing-motion.js, landing-materials.js, speaker-scene.js и light-drag.js. Они не минифицированы. Фрагменты ниже приведены в контексте этих модулей: переменная g обозначает GSAP, а desktop — выбранный профиль устройства. Полный HTML, стили и подключение зависимостей есть в архиве.
<figure class="showcase-media" data-image-reveal="pixels">
<div class="motion-image-scroll">
<div class="motion-image-pointer">
<img src="study.jpg" alt="Исследование формы">
</div>
</div>
<!-- Маска создаётся модулем анимации -->
</figure>Свяжите таймлайн с положением страницы
IntersectionObserver подходит для запуска однократного появления. Для управляемого раскрытия используется другой контракт: прогресс анимации следует за скроллом. ScrollTrigger связывает начало и конец диапазона с элементом, а числовой scrub добавляет сглаживание.
В этом фрагменте start: 'top 96%' означает начало, когда верх кадра достигает 96% высоты окна. Конец различается у фотографии и целого блока. Значение scrub: .8 сглаживает догоняющее движение на ПК; это не длительность полного раскрытия, она зависит от прокрутки.
function scrollReveal(target,build,{block=false}={}){
const timeline=g.timeline({paused:true,defaults:{ease:'none'}});
build(timeline);
const trigger=window.ScrollTrigger.create({trigger:target,animation:timeline,
start:'top 96%',end:block?(desktop?'top 15%':'top 22%'):(desktop?'top 18%':'top 32%'),
scrub:desktop?.8:.4,invalidateOnRefresh:true});
target.dataset.scrollReveal=block?'erase':target.dataset.imageReveal||'soft';
const focus=()=>{trigger.kill(false,true);timeline.progress(1).pause()};
target.addEventListener('focusin',focus);
cleanups.push(()=>{trigger.kill();timeline.kill();target.removeEventListener('focusin',focus);delete target.dataset.scrollReveal});
}У таймлайна нет автозапуска при первом пересечении экрана. Маска остаётся в DOM, поэтому движение вверх может восстановить предыдущую стадию. При фокусе с клавиатуры содержимое полностью открывается: управление важнее декоративного состояния. Во время пересборки профиля trigger и timeline удаляются.
Мозаика: ячейки поверх настоящей фотографии
Базовый кадр уже находится под маской. Временная сетка состоит из 12 × 8 ячеек на ПК и 7 × 5 на мобильном профиле. При раскрытии меняются масштаб и прозрачность ячеек, а не размеры страницы. Детерминированный порядок даёт диагональную волну с вариацией; при возврате вверх рисунок не меняется случайно.
function tiles(tl,figure,type,at){
const layer=cover(figure,'motion-reveal-cover motion-'+type);
const columns=type==='pixels'?(desktop?12:7):(desktop?7:4),rows=type==='pixels'?(desktop?8:5):1;
layer.style.gridTemplateColumns=`repeat(${columns},1fr)`;
layer.style.gridTemplateRows=`repeat(${rows},1fr)`;
const cells=Array.from({length:columns*rows},(_,i)=>{
const cell=document.createElement('span');layer.append(cell);
if(type==='scan')cell.style.transformOrigin=i%2?'center bottom':'center top';
return cell;
});
g.set(layer,{opacity:1});
if(type==='pixels'){
// A diagonal wave with deterministic variation reads as an assembling image,
// rather than a checkerboard wipe. No per-frame DOM work is required.
const ordered=cells.map((cell,i)=>({cell,rank:(i%columns)/columns*.45+Math.floor(i/columns)/rows*.25+((i*37)%101)/101*.5})).sort((a,b)=>a.rank-b.rank).map(item=>item.cell);
tl.fromTo(ordered,{scale:1,opacity:1},{scale:.04,opacity:0,duration:desktop?.42:.28,stagger:{amount:desktop?.68:.38},ease:'power2.inOut'},at+.04);
}else tl.fromTo(cells,{scaleY:1},{scaleY:0,duration:desktop?.78:.48,stagger:desktop?.065:.05,ease:'power3.inOut'},at+.04);
tl.to(layer,{opacity:0,duration:.1,ease:'none'},at+(desktop?1.14:.7));
}Функция cover() создаёт неинтерактивный слой с aria-hidden="true". Он не перехватывает клики. Удалять его после первого прохождения нельзя: это сломало бы обратное раскрытие. Удаление выполняется при отключении движения, смене профиля и уходе со страницы.
«Ластик»: одна SVG-маска на изображение и текст
В блоке Painting маска покрывает всю карточку. Белое поле сохраняет фоновый слой, а чёрный широкий SVG-путь постепенно вырезает в нём прозрачные проходы. Изменение strokeDashoffset открывает и изображение, и подпись в одном ритме. Финальный участок таймлайна убирает остаточную непрозрачность слоя.
Показать полный фрагмент SVG-«ластика»
function erase(tl,root){
const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');
svg.classList.add('motion-erase-cover');svg.setAttribute('aria-hidden','true');
svg.setAttribute('viewBox','0 0 1000 650');svg.setAttribute('preserveAspectRatio','none');
const id='erase-'+root.id,defs=document.createElementNS(ns,'defs'),mask=document.createElementNS(ns,'mask');
mask.id=id;mask.setAttribute('maskUnits','userSpaceOnUse');mask.setAttribute('x','0');mask.setAttribute('y','0');mask.setAttribute('width','1000');mask.setAttribute('height','650');
const field=document.createElementNS(ns,'rect');field.setAttribute('width','1000');field.setAttribute('height','650');field.setAttribute('fill','white');
const path=document.createElementNS(ns,'path');
path.setAttribute('d','M-150 0H1150Q1250 54 1150 108H-150Q-250 162-150 216H1150Q1250 270 1150 324H-150Q-250 378-150 432H1150Q1250 486 1150 540H-150Q-250 594-150 648H1150');
path.setAttribute('fill','none');path.setAttribute('stroke','black');path.setAttribute('stroke-width','164');path.setAttribute('stroke-linecap','round');
mask.append(field,path);defs.append(mask);
const paint=document.createElementNS(ns,'rect');paint.setAttribute('width','1000');paint.setAttribute('height','650');paint.setAttribute('fill','var(--bg)');paint.setAttribute('mask',`url(#${id})`);
svg.append(defs,paint);root.append(svg);cleanups.push(()=>svg.remove());
const length=path.getTotalLength(),duration=desktop?1.55:1.05;
path.style.strokeDasharray=`${length} ${length}`;g.set(svg,{opacity:1});
tl.fromTo(path,{strokeDashoffset:length},{strokeDashoffset:0,duration,ease:'none'},0);
tl.to(svg,{opacity:0,duration:.15,ease:'none'},duration-.04);
}Такой путь не является курсором-кистью: человеку не нужно вручную закрашивать каждый угол. Он управляет общим прогрессом через прокрутку. SVG находится в границах конкретного блока и не создаёт маску на всю длинную страницу.
Ребристое стекло: Canvas, затем чёткое состояние
В настольном профиле поверх изображения рисуется второй кадр на Canvas. Каждый из 30 вертикальных рёбер разбит ещё на четыре узких участка. Они берутся из исходного изображения с небольшим смещением. Переменные указателя меняют силу смещения и блик. Это реальное перераспределение участков фотографии; одних полос поверх изображения для такого эффекта недостаточно.
Canvas drawImage() позволяет брать прямоугольный участок источника и рисовать его в целевой области. В коде sx, sy и scale предварительно рассчитываются для обрезки, соответствующей object-fit: cover.
for(let i=0;i<30;i++){
const x=i*rib,influence=.45+.55*Math.max(0,1-Math.abs(x/w-point.x)/.5);
for(let j=0;j<4;j++){
const dx=x+j*rib/4,sw=rib/4+1;
const bend=(Math.sin((j/4-.5)*Math.PI)*9+(point.x-.5)*17)*influence;
const sourceX=Math.max(0,Math.min(nw-sw/scale,sx+(dx+bend)/scale));
ctx.drawImage(image,sourceX,sy,sw/scale,h/scale,dx,0,sw,h);
}
const sheen=ctx.createLinearGradient(x,0,x+rib,0);
sheen.addColorStop(0,'rgba(255,255,255,.16)');sheen.addColorStop(.25,'rgba(255,255,255,.025)');sheen.addColorStop(.8,'rgba(0,0,0,.015)');sheen.addColorStop(1,'rgba(0,0,0,.15)');
ctx.fillStyle=sheen;ctx.fillRect(x,0,rib,h);
}Canvas ограничен по размеру и обновляется после загрузки изображения, изменения размеров или короткой интерполяции указателя. Постоянного цикла ради неподвижной фотографии нет. Вне видимой области отрисовка не запускается.
.fluted-canvas {
opacity: .78;
pointer-events: none;
transition: opacity 1.6s cubic-bezier(.2,.6,.2,1);
}
@media (min-width: 801px) and (hover: hover) and (pointer: fine) {
[data-material="fluted"]:hover .fluted-canvas { opacity: 0; }
}
[data-material="fluted"][data-glass-clear="true"] .fluted-canvas {
opacity: 0;
}Чёткий оригинал всё время остаётся под Canvas. При наведении верхний слой исчезает, при уходе возвращается. Для инфографики применяется другой материал: полупрозрачные CSS-карточки и backdrop-filter над мягкими фоновыми формами. Это веб-интерпретация стекла, не реализация нативного Apple Liquid Glass.
Касание не должно конфликтовать с прокруткой
На телефоне нет настольного Canvas-преломления: его заменяют лёгкое ребрение и небольшой расфокус. Касание переключает атрибут data-glass-clear. Реальная проблема здесь — отличить тап от движения страницы и не потерять уже принятый тап между pointerup и click.
В Pointer Events мышь, перо и касание имеют общий набор событий. В проекте движение больше 10 CSS-пикселей отменяет кандидата в тап; pointercancel тоже сбрасывает жест. Уход touch-указателя после pointerup не уничтожает уже принятое касание до события click.
Код переключения, отмены жеста и клавиатуры
let gesture=null,acceptedTap=false;
const clear=value=>{host.dataset.glassClear=String(value);host.setAttribute('aria-pressed',String(value))};
const toggle=()=>clear(host.dataset.glassClear!=='true');clear(false);
listen(host,'pointerdown',e=>{
acceptedTap=false;
if(!e.isPrimary||e.button!==0||(desktop&&e.pointerType==='mouse'))return;
gesture={id:e.pointerId,x:e.clientX,y:e.clientY};
},{passive:true});
listen(host,'pointermove',e=>{if(gesture?.id===e.pointerId&&Math.hypot(e.clientX-gesture.x,e.clientY-gesture.y)>10)gesture=null},{passive:true});
listen(host,'pointerup',e=>{acceptedTap=gesture?.id===e.pointerId&&Math.hypot(e.clientX-gesture.x,e.clientY-gesture.y)<=10;gesture=null},{passive:true});
listen(host,'pointercancel',()=>{gesture=null;acceptedTap=false},{passive:true});
// Touch pointers leave the hit-test area after pointerup, sometimes before
// click. Keep an already accepted tap until that click consumes it.
listen(host,'pointerleave',()=>{if(gesture){gesture=null;acceptedTap=false}},{passive:true});
listen(host,'click',e=>{if(acceptedTap||e.detail===0)toggle();acceptedTap=false});
listen(host,'keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();if(!e.repeat)toggle()}});Кадр получает роль кнопки, tabindex и aria-pressed. Enter и Space переключают тот же режим. Повтор клавиши не должен бесконечно менять состояние. При удалении эффекта дополнительные атрибуты и слушатели очищаются.
3D-свет: перенесите координаты указателя в сцену
Светящаяся точка — визуальное представление настоящего PointLight. Чтобы его перемещать, экранные координаты превращаются в нормализованные координаты устройства. Луч из камеры пересекается с плоскостью, проходящей через исходное положение источника и параллельной экрану.
Raycaster задаёт луч, а пересечение выполняется через ray.intersectPlane(). Смещение от точки захвата сохраняется: свет не прыгает в центр большой сенсорной области при начале перетаскивания.
export function createLightDrag(camera){
const raycaster=new Raycaster(),plane=new Plane(),normal=new Vector3(),hit=new Vector3(),offset=new Vector3(),pointer=new Vector2();
let rect;
function cast(x,y,constrain){
const inset=Math.min(24,rect.width/4,rect.height/4);
if(constrain){x=Math.max(rect.left+inset,Math.min(rect.left+rect.width-inset,x));y=Math.max(rect.top+inset,Math.min(rect.top+rect.height-inset,y))}
pointer.set((x-rect.left)/rect.width*2-1,-(y-rect.top)/rect.height*2+1);
raycaster.setFromCamera(pointer,camera);
return raycaster.ray.intersectPlane(plane,hit);
}
return {
begin(position,x,y,bounds){rect={left:bounds.left,top:bounds.top,width:bounds.width,height:bounds.height};camera.getWorldDirection(normal);plane.setFromNormalAndCoplanarPoint(normal,position);if(!cast(x,y,false))return false;offset.copy(position).sub(hit);return true},
move(x,y){if(!rect||!cast(x,y,true))return null;return hit.clone().add(offset)},
end(){rect=null}
};
}В обработчике движения результат ограничивается допустимой высотой и расстоянием, затем обновляет положение PointLight и его видимого маркера. Материалы получают новые блики, а поверхность — новую тень. На время перетаскивания автоматическое вращение приостанавливается. Само движение света отвечает сразу, без декоративного запаздывания.
Настройка теней требует включённого renderer.shadowMap, castShadow у источника и модели и receiveShadow у принимающей поверхности. Яркая сфера сама по себе не освещает объекты — источником остаётся PointLight. Открыть отдельную 3D-демонстрацию →
Спокойный режим и проверка жизненного цикла
prefers-reduced-motion сообщает о пользовательском предпочтении уменьшить движение. В App3D к нему добавлен ручной переключатель. При активации удаляются таймлайны, маски, оптические Canvas и фирменный курсор; текст и исходные изображения остаются доступны.
@media (prefers-reduced-motion: reduce) {
.motion-reveal-cover, .motion-erase-cover, .fluted-canvas {
display: none !important;
}
.motion-image-pointer, .showcase-media img {
opacity: 1 !important;
transform: none !important;
filter: none !important;
clip-path: none !important;
}
}CSS-страховка не заменяет очистку JS. При пересечении мобильного breakpoint сначала отключаются наблюдатели, слушатели, быстрые твины и ScrollTrigger, затем снимаются старые стили и создаётся новый профиль. Иначе после нескольких изменений окна легко получить две маски или невидимый блок.
- Остановить скролл в середине раскрытия и убедиться, что оно не доигрывает само.
- Прокрутить вверх и проверить восстановление маски.
- Проверить обычный тап, повторный тап, прокрутку и pointercancel.
- Снять стекло мышью, уйти с кадра и проверить его возвращение.
- Включить спокойный режим в середине страницы и открыть все кадры без движения.
- Несколько раз изменить ширину окна и проверить число слоёв и отсутствие горизонтального переполнения.
Исходники и локальный запуск
Код проекта
Запустить App3D локально
HTML, CSS, неминифицированные JS-модули, иллюстрации, шрифты и зависимости. Отдельная сборка не нужна: распакуйте архив и запустите локальный HTTP-сервер по инструкции README.
Скачать исходники · ZIPВ архиве исполняемые JS-файлы остаются читаемыми. Для изучения начните с workshop/landing-motion.js — там раскрытие, мозаика и SVG-путь. Оптические материалы и жесты находятся в landing-materials.js, сцена — в speaker-scene.js, геометрия перетаскивания — в light-drag.js.
python -m http.server 8768 --bind 127.0.0.1
# Затем откройте http://127.0.0.1:8768/Для самой страницы не требуется Node-сборка. Открытие через file:// не заменяет HTTP-сервер: модульные импорты рассчитаны на веб-окружение. Посмотреть кейс или прочитать дизайнерский разбор.