Логотип Зефирнет

CSS Slinky в 3D? Вызов принят!

Дата:

Брейдон Койер Недавно запустил ежемесячный Вызов искусства CSS. Он на самом деле связался со мной по поводу пожертвования экземпляра моей книги. Перемещайте вещи с помощью CSS использовать в качестве приза для победителя конкурса — что я был более чем счастлив сделать!

Задача первого месяца? Весна. И когда мы думали, что сделать для челленджа, на ум сразу же пришел Slinkys. Вы знаете Слинкиса, верно? В этой классической игрушке вы сбиваете лестницу, и она движется по собственной инерции.

Крадущийся Слинки

Можем ли мы создать Слинки, спускающегося по лестнице в CSS? Мне нравятся именно такие задачи, поэтому я подумал, что мы могли бы решить их вместе в этой статье. Готовы бросить? (Каламбур.)

Настройка Slinky HTML

Давайте сделаем это гибким. (Это не каламбур.) Под этим я подразумеваю, что мы хотим иметь возможность контролировать поведение Slinky с помощью пользовательских свойств CSS, что дает нам возможность гибко менять значения, когда нам это нужно.

Вот как я настраиваю сцену, для краткости написанную на мопсе:

- const RING_COUNT = 10;
.container
  .scene
    .plane(style=`--ring-count: ${RING_COUNT}`)
      - let rings = 0;
      while rings < RING_COUNT
        .ring(style=`--index: ${rings};`)
        - rings++;

Эти встроенные пользовательские свойства — это простой способ обновить количество колец, и они пригодятся, когда мы углубимся в эту задачу. Код выше дает нам 10 кольца с HTML, который выглядит что-то вроде этого при компиляции:

<div class="container">
  <div class="scene">
    <div class="plane" style="--ring-count: 10">
      <div class="ring" style="--index: 0;"></div>
      <div class="ring" style="--index: 1;"></div>
      <div class="ring" style="--index: 2;"></div>
      <div class="ring" style="--index: 3;"></div>
      <div class="ring" style="--index: 4;"></div>
      <div class="ring" style="--index: 5;"></div>
      <div class="ring" style="--index: 6;"></div>
      <div class="ring" style="--index: 7;"></div>
      <div class="ring" style="--index: 8;"></div>
      <div class="ring" style="--index: 9;"></div>
    </div>
  </div>
</div>

Первоначальный Slinky CSS

Нам понадобятся стили! То, что мы хотим, это трехмерная сцена. Я не забываю о некоторых вещах, которые мы, возможно, захотим сделать позже, так что идея заключается в том, чтобы иметь дополнительный компонент-оболочку с .scene класса.

Давайте начнем с определения некоторых свойств нашей «бесконечно-тонкой» сцены:

:root {
  --border-width: 1.2vmin;
  --depth: 20vmin;
  --stack-height: 6vmin;
  --scene-size: 20vmin;
  --ring-size: calc(var(--scene-size) * 0.6);
  --plane: radial-gradient(rgb(0 0 0 / 0.1) 50%, transparent 65%);
  --ring-shadow: rgb(0 0 0 / 0.5);
  --hue-one: 320;
  --hue-two: 210;
  --blur: 10px;
  --speed: 1.2s;
  --bg: #fafafa;
  --ring-filter: brightness(1) drop-shadow(0 0 0 var(--accent));
}

Эти свойства определяют характеристики нашего Slinky и сцены. Для большинства 3D-сцен CSS мы собираемся установить transform-style пересечь границу:

* {
  box-sizing: border-box;
  transform-style: preserve-3d;
}

Теперь нам нужны стили для нашего .scene. Хитрость заключается в том, чтобы перевести .plane так что похоже, что наш CSS Slinky бесконечно движется вниз по лестнице. Мне пришлось повозиться, чтобы все получилось именно так, как я хочу, так что потерпите пока магическое число, так как оно будет иметь смысл позже.

.container {
  /* Define the scene's dimensions */
  height: var(--scene-size);
  width: var(--scene-size);
  /* Add depth to the scene */
  transform:
    translate3d(0, 0, 100vmin)
    rotateX(-24deg) rotateY(32deg)
    rotateX(90deg)
    translateZ(calc((var(--depth) + var(--stack-height)) * -1))
    rotate(0deg);
}
.scene,
.plane {
  /* Ensure our container take up the full .container */
  height: 100%;
  width: 100%;
  position: relative;
}
.scene {
  /* Color is arbitrary */
  background: rgb(162 25 230 / 0.25);
}
.plane {
  /* Color is arbitrary */
  background: rgb(25 161 230 / 0.25);
  /* Overrides the previous selector */
  transform: translateZ(var(--depth));
}

Здесь происходит довольно много с .container трансформация. Конкретно:

  • translate3d(0, 0, 100vmin): Это приносит .container вперед и предотвращает обрезание нашей 3D-работы телом. мы не используем perspective на этом уровне, так что мы можем уйти с ним.
  • rotateX(-24deg) rotateY(32deg): Это вращает сцену в зависимости от наших предпочтений.
  • rotateX(90deg): Это вращает .container на четверть оборота, что сглаживает .scene и .plane по умолчанию. В противном случае два слоя выглядели бы как верх и низ 3D-куба.
  • translate3d(0, 0, calc((var(--depth) + var(--stack-height)) * -1)): Мы можем использовать это, чтобы переместить сцену и центрировать ее по оси Y (ну, на самом деле по оси Z). Это в глазах дизайнера. Здесь мы используем --depth и --stack-height центрировать вещи.
  • rotate(0deg): Хотя в данный момент он не используется, мы можем захотеть повернуть сцену или анимировать вращение сцены позже.

Чтобы представить, что происходит с .container, проверьте эту демонстрацию и нажмите в любом месте, чтобы увидеть transform применено (извините, только Chromium. 😭):

Теперь у нас есть стилизованная сцена! 💪

Укладка колец Слинки

Именно здесь эти пользовательские свойства CSS сыграют свою роль. У нас есть встроенные свойства --index и --ring-count из нашего HTML. У нас также есть предопределенные свойства в CSS, которые мы видели ранее на :root.

Встроенные свойства будут играть роль в позиционировании каждого кольца:

.ring {
  --origin-z:
    calc(
      var(--stack-height) - (var(--stack-height) / var(--ring-count))
      * var(--index)
    );
  --hue: var(--hue-one);
  --accent: hsl(var(--hue) 100% 55%);
  height: var(--ring-size);
  width: var(--ring-size);
  border-radius: 50%;
  border: var(--border-width) solid var(--accent);
  position: absolute;
  top: 50%;
  left: 50%;
  transform-origin: calc(100% + (var(--scene-size) * 0.2)) 50%;
  transform:
    translate3d(-50%, -50%, var(--origin-z))
    translateZ(0)
    rotateY(0deg);
}
.ring:nth-of-type(odd) {
  --hue: var(--hue-two);
}

Обратите внимание на то, как мы рассчитываем --origin-z значение, а также то, как мы позиционируем каждое кольцо с помощью transform свойство. Это происходит после позиционирования каждого кольца с помощью position: absolute .

Также стоит отметить, как мы меняем цвет каждого кольца в этом последнем наборе правил. Когда я впервые реализовал это, я хотел создать облегающую радугу, где кольца проходили сквозь оттенки. Но это добавляет немного сложности эффекту.

Теперь у нас есть несколько колец на поднятом .plane:

Преобразование колец Slinky

Пришло время сдвинуть дело с мертвой точки! Вы, наверное, заметили, что мы установили transform-origin на каждой .ring как это:

.ring {
  transform-origin: calc(100% + (var(--scene-size) * 0.2)) 50%;
}

Это основано на .scene размер. Тот 0.2 значение равно половине оставшегося доступного размера .scene после .ring позиционируется.

Мы могли бы привести это в порядок!

:root {
  --ring-percentage: 0.6;
  --ring-size: calc(var(--scene-size) * var(--ring-percentage));
  --ring-transform:
    calc(
      100%
      + (var(--scene-size) * ((1 - var(--ring-percentage)) * 0.5))
    ) 50%;
}

.ring {
  transform-origin: var(--ring-transform);
}

Почему так transform-origin? Ну, нам нужно, чтобы кольцо выглядело так, будто оно смещается от центра. Играя с transform отдельного кольца - хороший способ выработать transform мы хотим подать заявку. Переместите ползунок в этой демонстрации, чтобы увидеть переворот кольца:

Добавьте все кольца обратно, и мы можем перевернуть всю стопку!

Хм, но они не падают на следующую ступеньку. Как мы можем заставить каждое кольцо упасть в правильное положение?

Ну, у нас есть расчет --origin-z, так что давайте посчитаем --destination-z так что глубина меняется как кольца transform. Если у нас есть кольцо наверху стека, оно должно оказаться внизу после падения. Мы можем использовать наши пользовательские свойства, чтобы указать место назначения для каждого кольца:

ring {
  --destination-z: calc(
    (
      (var(--depth) + var(--origin-z))
      - (var(--stack-height) - var(--origin-z))
    ) * -1
  );
  transform-origin: var(--ring-transform);
  transform:
    translate3d(-50%, -50%, var(--origin-z))
    translateZ(calc(var(--destination-z) * var(--flipped, 0)))
    rotateY(calc(var(--flipped, 0) * 180deg));
}

Теперь попробуйте переместить стек! Мы приближаемся. 🙌

Анимация колец

Мы хотим, чтобы наше кольцо перевернулось, а затем упало. Первая попытка может выглядеть примерно так:

.ring {
  animation-name: slink;
  animation-duration: 2s;
  animation-fill-mode: both;
  animation-iteration-count: infinite;
}

@keyframes slink {
  0%, 5% {
    transform:
      translate3d(-50%, -50%, var(--origin-z))
      translateZ(0)
      rotateY(0deg);
  }
  25% {
    transform:
      translate3d(-50%, -50%, var(--origin-z))
      translateZ(0)
      rotateY(180deg);
  }
  45%, 100% {
    transform:
      translate3d(-50%, -50%, var(--origin-z))
      translateZ(var(--destination-z))
      rotateY(180deg);
  }
}

Уф, все не так!

Но это только потому, что мы не используем animation-delay. Все кольца, гм, крадущийся в то же время. Давайте представим animation-delay на основе --index кольца, чтобы они крались по очереди.

.ring {
  animation-delay: calc(var(--index) * 0.1s);
}

Хорошо, это действительно «лучше». Но время еще не пришло. Но больше бросается в глаза недостаток animation-delay. Он применяется только на первой итерации анимации. После этого мы теряем эффект.

На этом этапе давайте раскрасим кольца, чтобы они проходили через колесо оттенков. Это облегчит просмотр того, что происходит.

.ring {
  --hue: calc((360 / var(--ring-count)) * var(--index));
}

Так-то лучше! ✨

Вернемся к вопросу. Поскольку мы не можем указать задержку, применяемую к каждой итерации, мы также не можем получить желаемый эффект. Для нашего Слинки, если бы мы могли иметь постоянный animation-delay, возможно, мы сможем добиться желаемого эффекта. И мы могли бы использовать один ключевой кадр, полагаясь на наши настраиваемые свойства области действия. Даже animation-repeat-delay может быть интересным дополнением.

Эта функция доступна в решениях для анимации JavaScript. Например, GreenSock позволяет указать delay и еще один repeatDelay.

Но наш пример Slinky — не самый простой способ проиллюстрировать эту проблему. Давайте разберем это на базовом примере. Рассмотрим две коробки. И вы хотите, чтобы они чередовали вращение.

Как это сделать с помощью CSS и без всяких «хитростей»? Одна идея состоит в том, чтобы добавить задержку к одному из полей:

.box {
  animation: spin 1s var(--delay, 0s) infinite;
}
.box:nth-of-type(2) {
  --delay: 1s;
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

Но это не сработает, потому что красный ящик будет продолжать вращаться. И то же самое будет с синим после его начального animation-delay.

Что-то вроде ГринСок, тем не менее, мы можем относительно легко добиться желаемого эффекта:

import gsap from 'https://cdn.skypack.dev/gsap'

gsap.to('.box', {
  rotate: 360,
  /**
   * A function based value, means that the first box has a delay of 0 and
   * the second has a delay of 1
  */
  delay: (index) > index,
  repeatDelay: 1,
  repeat: -1,
  ease: 'power1.inOut',
})

И вот оно!

Но как мы можем сделать это без JavaScript?

Что ж, нам предстоит «взломать» наш @keyframes и полностью отказаться от animation-delay. Вместо этого мы будем дополнять @keyframes с пустым пространством. Это связано с различными причудами, но давайте сначала создадим новый ключевой кадр. Это полностью повернет элемент дважды:

@keyframes spin {
  50%, 100% {
    transform: rotate(360deg);
  }
}

Как будто мы сократили ключевой кадр пополам. И теперь нам придется удвоить animation-duration чтобы получить одинаковую скорость. Без использования animation-delay, мы могли бы попробовать установить animation-direction: reverse на второй коробке:

.box {
  animation: spin 2s infinite;
}

.box:nth-of-type(2) {
  animation-direction: reverse;
}

Почти.

Вращение неправильное. Мы могли бы использовать элемент-обертку и повернуть его, но это может быть сложно, так как нужно сбалансировать больше вещей. Другой подход заключается в создании двух ключевых кадров вместо одного:

@keyframes box-one {
  50%, 100% {
    transform: rotate(360deg);
  }
}
@keyframes box-two {
  0%, 50% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

И вот что у нас есть:

Это было бы намного проще, если бы у нас был способ указать задержку повторения примерно так:

/* Hypothetical! */
animation: spin 1s 0s 1s infinite;

Или, если бы повторяющаяся задержка совпадала с начальной задержкой, мы могли бы иметь для нее комбинатор:

/* Hypothetical! */
animation: spin 1s 1s+ infinite;

Это наверняка станет интересным дополнением!

Итак, нам нужны ключевые кадры для всех этих колец?

Да, если мы хотим последовательной задержки. И нам нужно сделать это на основе того, что мы собираемся использовать в качестве окна анимации. Все кольца должны «ускользнуть» и установиться до повторения ключевых кадров.

Было бы ужасно писать от руки. Но именно поэтому у нас есть препроцессоры CSS, верно? Ну, по крайней мере, до тех пор, пока мы не получим циклы и некоторые дополнительные функции пользовательских свойств в Интернете. 😉

Излюбленным оружием сегодняшнего дня будет Stylus. Это мой любимый препроцессор CSS в течение некоторого времени. Привычка означает, что я не перешел на Sass. Кроме того, мне нравится отсутствие в Stylus необходимой грамматики и гибкости.

Хорошо, что нам нужно написать это только один раз:

// STYLUS GENERATED KEYFRAMES BE HERE...
$ring-count = 10
$animation-window = 50
$animation-step = $animation-window / $ring-count

for $ring in (0..$ring-count)
  // Generate a set of keyframes based on the ring index
  // index is the ring
  $start = $animation-step * ($ring + 1)
  @keyframes slink-{$ring} {
    // In here is where we need to generate the keyframe steps based on ring count and window.
    0%, {$start * 1%} {
      transform
        translate3d(-50%, -50%, var(--origin-z))
        translateZ(0)
        rotateY(0deg)
    }
    // Flip without falling
    {($start + ($animation-window * 0.75)) * 1%} {
      transform
        translate3d(-50%, -50%, var(--origin-z))
        translateZ(0)
        rotateY(180deg)
    }
    // Fall until the cut-off point
    {($start + $animation-window) * 1%}, 100% {
      transform
        translate3d(-50%, -50%, var(--origin-z))
        translateZ(var(--destination-z))
        rotateY(180deg)
    }
  }

Вот что означают эти переменные:

  • $ring-count: Количество колец в нашем обтекателе.
  • $animation-window: Это процент ключевого кадра, в котором мы можем прокрасться. В нашем примере мы говорим, что хотим прокрасться 50% ключевых кадров. Остальное 50% надо привыкнуть к задержкам.
  • $animation-step: Это рассчитанное смещение для каждого кольца. Мы можем использовать это для вычисления процента уникальных ключевых кадров для каждого кольца.

Вот как он компилируется в CSS, по крайней мере, для первых двух итераций:

Посмотреть полный код
@keyframes slink-0 {
  0%, 4.5% {
    transform:
      translate3d(-50%, -50%, var(--origin-z))
      translateZ(0)
      rotateY(0deg);
  }
  38.25% {
    transform:
      translate3d(-50%, -50%, var(--origin-z))
      translateZ(0)
      rotateY(180deg);
  }
  49.5%, 100% {
    transform:
      translate3d(-50%, -50%, var(--origin-z))
      translateZ(var(--destination-z))
      rotateY(180deg);
  }
}
@keyframes slink-1 {
  0%, 9% {
    transform:
      translate3d(-50%, -50%, var(--origin-z))
      translateZ(0)
      rotateY(0deg);
  }
  42.75% {
    transform:
      translate3d(-50%, -50%, var(--origin-z))
      translateZ(0)
      rotateY(180deg);
  }
  54%, 100% {
    transform:
       translate3d(-50%, -50%, var(--origin-z))
       translateZ(var(--destination-z))
       rotateY(180deg);
  }
}

Последнее, что нужно сделать, это применить каждый набор ключевых кадров к каждому кольцу. Мы можем сделать это, используя нашу разметку, если мы хотим, обновив ее, чтобы определить как --index и a --name:

- const RING_COUNT = 10;
.container
  .scene
    .plane(style=`--ring-count: ${RING_COUNT}`)
      - let rings = 0;
      while rings < RING_COUNT
        .ring(style=`--index: ${rings}; --name: slink-${rings};`)
        - rings++;

Что дает нам это при компиляции:

<div class="container">
  <div class="scene">
    <div class="plane" style="--ring-count: 10">
      <div class="ring" style="--index: 0; --name: slink-0;"></div>
      <div class="ring" style="--index: 1; --name: slink-1;"></div>
      <div class="ring" style="--index: 2; --name: slink-2;"></div>
      <div class="ring" style="--index: 3; --name: slink-3;"></div>
      <div class="ring" style="--index: 4; --name: slink-4;"></div>
      <div class="ring" style="--index: 5; --name: slink-5;"></div>
      <div class="ring" style="--index: 6; --name: slink-6;"></div>
      <div class="ring" style="--index: 7; --name: slink-7;"></div>
      <div class="ring" style="--index: 8; --name: slink-8;"></div>
      <div class="ring" style="--index: 9; --name: slink-9;"></div>
    </div>
  </div>
</div>

И тогда наш стиль можно соответствующим образом обновить:

.ring {
  animation: var(--name) var(--speed) both infinite cubic-bezier(0.25, 0, 1, 1);
}

Время решает все. Итак, мы отказались от стандартного animation-timing-function и мы используем cubic-bezier. Мы также используем --speed пользовательское свойство, которое мы определили в начале.

О да. Теперь у нас есть хитрый CSS Slinky! Поиграйте с некоторыми переменными в коде и посмотрите, какое другое поведение вы можете получить.

Создание бесконечной анимации

Теперь, когда мы убрали самую сложную часть, мы можем сделать так, чтобы анимация повторялась бесконечно. Чтобы сделать это, мы собираемся перевести сцену, когда наш Слинки крадется, чтобы было похоже, что он крадется обратно в исходное положение.

.scene {
  animation: step-up var(--speed) infinite linear both;
}

@keyframes step-up {
  to {
    transform: translate3d(-100%, 0, var(--depth));
  }
}

Вау, это стоило совсем немного усилий!

Мы можем удалить цвета платформы из .scene и .plane чтобы анимация не была слишком резкой:

Почти готово! Последнее, на что нужно обратить внимание, это то, что стопка колец переворачивается, прежде чем снова ускользнуть. Именно здесь мы упоминали ранее, что использование цвета может пригодиться. Измените количество колец на нечетное число, например 11, и вернитесь к чередованию цвета кольца:

Бум! У нас есть рабочий CSS slinky! Это тоже настраивается!

Веселые вариации

Как насчет эффекта «перевертыша»? Под этим я подразумеваю, что Slink может красться альтернативными путями. Если мы добавим в сцену дополнительный элемент-оболочку, мы сможем повернуть сцену на 180deg на каждом слинке.

- const RING_COUNT = 11;
.container
  .flipper
    .scene
      .plane(style=`--ring-count: ${RING_COUNT}`)
        - let rings = 0;
        while rings < RING_COUNT
          .ring(style=`--index: ${rings}; --name: slink-${rings};`)
          - rings++;

Что касается анимации, мы можем использовать steps() функцию синхронизации и использовать в два раза больше --speed:

.flipper {
  animation: flip-flop calc(var(--speed) * 2) infinite steps(1);
  height: 100%;
  width: 100%;
}

@keyframes flip-flop {
  0% {
    transform: rotate(0deg);
  }
  50% {
    transform: rotate(180deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

И последнее, но не менее важное: давайте изменим способ .scene элемент step-up анимация работает. Ему больше не нужно двигаться по оси x.

@keyframes step-up {
  0% {
    transform: translate3d(-50%, 0, 0);
  }
  100% {
    transform: translate3d(-50%, 0, var(--depth));
  }
}

Обратите внимание animation-timing-function что мы используем. Это использование steps(1) это то, что делает это возможным.

Если вы хотите еще одно забавное использование steps()зацени это #SpeedyCSSTСовет!

Для дополнительного штриха мы могли бы медленно вращать всю сцену:

.container {
  animation: rotate calc(var(--speed) * 40) infinite linear;
}
@keyframes rotate {
  to {
    transform:
      translate3d(0, 0, 100vmin)
      rotateX(-24deg)
      rotateY(-32deg)
      rotateX(90deg)
      translateZ(calc((var(--depth) + var(--stack-height)) * -1))
      rotate(360deg);
  }
}

Мне это нравится! Конечно, стиль субъективен… поэтому я сделал небольшое приложение, которое вы можете использовать для настройки Slinky:

А вот «Оригинальная» и «Флип-флоп» версии, которые я продвинул немного дальше с тенями и темами.

Финальные демо

Это оно!

Это, по крайней мере, один из способов сделать Slinky на чистом CSS, одновременно трехмерным и настраиваемым. Конечно, вы можете не заниматься чем-то подобным каждый день, но это открывает перед вами интересные приемы CSS-анимации. Это также поднимает вопрос о том, имеет ли animation-repeat-delay свойство в CSS было бы полезно. Что вы думаете? Как вы думаете, есть ли для него хорошие варианты использования? Я хотел бы знать.

Обязательно поиграйте с кодом — он весь доступен в эта коллекция CodePen!

Spot_img

Последняя разведка

Spot_img

Чат с нами

Всем привет! Могу я чем-нибудь помочь?