К содержанию

Frontend / Motion / Код

Скролл, 3D и ребристое стекло: код эффектов App3D

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

Экран App3D с примером разбираемых приёмов
Практика из проекта App3D. Открыть лендинг ↗

Сначала разделите владельцев состояния

В App3D один и тот же кадр участвует в раскрытии, параллаксе и оптическом эффекте. Если несколько обработчиков меняют один transform, результат зависит от порядка обновлений. Поэтому у фотографии есть отдельные вложенные слои: внешний контейнер обрезает изображение, один внутренний слой отвечает за параллакс, другой содержит сам кадр и Canvas. Маска раскрытия лежит поверх них.

Основные исполняемые исходники — landing-motion.js, landing-materials.js, speaker-scene.js и light-drag.js. Они не минифицированы. Фрагменты ниже приведены в контексте этих модулей: переменная g обозначает GSAP, а desktop — выбранный профиль устройства. Полный HTML, стили и подключение зависимостей есть в архиве.

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 сглаживает догоняющее движение на ПК; это не длительность полного раскрытия, она зависит от прокрутки.

JavaScript · реальный scrollReveal из landing-motion
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 на мобильном профиле. При раскрытии меняются масштаб и прозрачность ячеек, а не размеры страницы. Детерминированный порядок даёт диагональную волну с вариацией; при возврате вверх рисунок не меняется случайно.

JavaScript · сетка и порядок раскрытия
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-«ластика»
JavaScript · erase из landing-motion
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.

JavaScript · фрагмент отрисовки ребристого кадра
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 ограничен по размеру и обновляется после загрузки изображения, изменения размеров или короткой интерполяции указателя. Постоянного цикла ради неподвижной фотографии нет. Вне видимой области отрисовка не запускается.

CSS · стекло исчезает при наведении
.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.

Код переключения, отмены жеста и клавиатуры
JavaScript · события стеклянного изображения
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(). Смещение от точки захвата сохраняется: свет не прыгает в центр большой сенсорной области при начале перетаскивания.

JavaScript · createLightDrag из проекта
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 и фирменный курсор; текст и исходные изображения остаются доступны.

CSS · страховка для неподвижного содержимого
@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.

Терминал · из распакованной папки app3d-landing
python -m http.server 8768 --bind 127.0.0.1
# Затем откройте http://127.0.0.1:8768/

Для самой страницы не требуется Node-сборка. Открытие через file:// не заменяет HTTP-сервер: модульные импорты рассчитаны на веб-окружение. Посмотреть кейс или прочитать дизайнерский разбор.