Home Blog CSS-Schattenspiele – Scroll Shadows mit «animation-timeline»

CSS-Schattenspiele – Scroll Shadows mit «animation-timeline»

Avatar von Jeff Chi

«Wer einen schönen Schatten werfen kann, achtet nicht auf den Schatten, sondern auf den Körper.»

aus «Frühling und Herbst des Lü Buwei»

Scroll Shadows sind ein toller Kniff im UX-Design, um anzuzeigen, dass es hinter einem Container-Rand noch weitergeht. Dafür gibt es zwar auch die native Scroll-Bar, doch je nach Gerät und Browser kann diese durchaus sehr subtil aussehen.

Ihr seid hier richtig, falls ihr versucht, CSS-Scroll-Shadows zu implementieren, aber dabei auf Probleme stosst, weil eure Inhalte die Schatten überdecken. Ich habe mir eine neue Lösung mit der CSS-Eigenschaft animation-timeline ausgedacht, die ich euch in diesem Artikel zeigen möchte.

⚠️

Zum Zeitpunkt der Veröffentlichung dieses Beitrags ist animation-timeline nur in Chromium-basierten Browsern verfügbar wie Google Chrome, Microsoft Edge oder Opera. Siehe: https://caniuse.com/mdn-css_properties_animation-timeline

Scroll Shadows sind zum Glück vorrangig eine ästhetische Verbesserung und keine essenzielle Funktion. Freuen wir uns auf eine Zukunft, in der alle Browser unseren Trick darstellen können.

Mit Schatten zeigen, statt zu verbergen

Ein weitverbreiteter CSS-Trick ermöglicht es uns, Scroll Shadows zu Containern hinzuzufügen. Sie verschwinden, wenn man das Ende des Containers erreicht hat, in beide Richtungen. So wird angezeigt, dass nun auch der Inhalt zu Ende ist.

Beispiel als Video

Vollständiger CSS-Code
@media ( prefers-reduced-motion: no-preference ) {
  .scroll-container {
    --white: #fff;
    --black: #000;
    --scroll-shadow-size: 1rem;

    background:
      /* Verlauf 1:
      - Weiss
      - Ganz oben in der Reihenfolge
      - Verharrt am oberen Container-Ende */
      linear-gradient(
        180deg,
        var( --white ) var( --scroll-shadow-size ),
        transparent
      ) center top,

      /* Verlauf 2:
      - Weiss
      - An zweiter Stelle in der Reihenfolge
      - Verharrt am unteren Container-Ende */
      linear-gradient(
        0deg,
        var( --white ) var( --scroll-shadow-size ),
        transparent
      ) center bottom,

      /* Verlauf 3:
      - Schwarz
      - Weiter unten in der Reihenfolge
      - Bewegt sich beim Scrollen am oberen Ende mit */
      linear-gradient(
        180deg,
        color-mix( in srgb, var( --black ), transparent 50% ),
        transparent
      ) center top,

      /* Verlauf 4:
      - Schwarz
      - Weiter unten in der Reihenfolge
      - Bewegt sich beim Scrollen am unteren Ende mit */
      linear-gradient(
        0deg,
        color-mix( in srgb, var( --black ), transparent 50% ),
        transparent
      ) center bottom,

      /* Hintergrund 5:
      Gewöhnlicher, weisser Hintergrund für den ganzen Container */
      var(--white);
    background-size:
      100% calc( 2 * var( --scroll-shadow-size ) ),
      100% calc( 2 * var( --scroll-shadow-size ) ),
      100% var( --scroll-shadow-size ),
      100% var( --scroll-shadow-size );
    background-attachment: local, local, scroll, scroll;
    background-repeat: no-repeat;
  }
}Code-Sprache: CSS (css)

Für alle Methoden, die ich euch zeigen werde, prüfe ich stets auf prefers-reduced-motion. Wir wollen die animierten Schatten nicht anzeigen, wenn jemand im Browser Animationen und Effekte abgestellt hat.

🎨

In den CSS-Ausschnitten habe ich den Code immer auf die Zeilen heruntergebrochen, die für die Darstellung notwendig sind. Die eingebetteten Beispiele enthalten noch zusätzliche Styles – wie Paddings, Margins und andere Effekte – um sie etwas hübscher zu machen.

Hinter der Kulisse

Diesen Trick habe ich mir nicht selbst ausgedacht – man findet ihn oft im Internet. Die Idee ist klug: Wir definieren die selten verwendete CSS-Eigenschaft background-attachment für vier verschiedene Hintergrund-Verläufe. Zwei werden der Scroll-Bewegung folgen, während zwei andere an den Enden des Containers verharren.

Beispiel als Video

--white: #0f0;
--black: #f00;Code-Sprache: CSS (css)

Im Beispiel habe ich die beweglichen Verläufe rot eingefärbt und die verharrenden grün. Weil die verharrenden Verläufe später in der Reihenfolge definiert werden, liegen sie «hinter» den beweglichen und werden von ihnen verdeckt, wenn man an die Container-Enden angelangt.

Das Problem mit den Hintergründen

Das funktioniert gut – bis im Scroll-Container Elemente mit Hintergründen vorkommen oder auch Bilder. Diese überlagern die Verläufe und machen den Effekt kaputt.

Beispiel als Video

Eine neue Lösung dank «animation-timeline»

Beispiel als Video

Vollständiger CSS-Code
@keyframes scroll-indicator {
  0% { 
    opacity: 0; 
  }
  15% { 
    opacity: 1; 
  }
  100% { 
    /* `animation-fill-mode` funktioniert hier nicht, deshalb legen wir den End-Zustand explizit fest. */
    opacity: 1;
  } 
}

@media ( prefers-reduced-motion: no-preference ) {
  @supports ( ( animation-timeline: scroll() ) ) {
    .scroll-container {
      --scroll-indicator-size: 1rem;

      position: relative;
      scroll-timeline-name: --scroll-timeline;
      scroll-timeline-axis: block;

      &::before,
      &::after {
        content: "";
        position: sticky;
        display: block;
        width: 100%;
        height: var( --scroll-shadow-size );
        opacity: 0;
        pointer-events: none;
        animation-name: scroll-indicator;
        animation-timeline: --scroll-timeline;
      }

      &::before {
        top: 0;
        background:
          linear-gradient(
            180deg,
            color-mix( in srgb, var( --black ), transparent 50% ),
            transparent
          );
      }

      &::after {
        animation-direction: reverse;
        bottom: 0;
        background:
          linear-gradient(
            0deg,
            color-mix( in srgb, var( --black ), transparent 50% ),
            transparent
          );
      }
    }
  }
}Code-Sprache: CSS (css)

Zeit für unseren eigentlichen Trick: Mit der Eigenschaft animation-timeline können wir Zeitachsen definieren, nach denen sich CSS-Animationen richten. Häufig wird das mit scroll-timeline kombiniert, um die Animationen an das Scroll-Verhalten der Benutzer:innen zu koppeln.

🖱️

Wollt ihr mehr über «scroll-driven animations» erfahren? Schaut doch mal auf die Demo-Seite, die wir dazu vorbereitet haben. Dort findet ihr Beispiele und Erklärungen.

scroll-timeline-name: --scroll-timeline;
scroll-timeline-axis: block;Code-Sprache: HTTP (http)

Mit diesen Zeilen definieren wir eine eigene Zeitachse namens --scroll-timeline auf dem Scroll-Container. Die Eigenschaft scroll-timeline-axis: block gibt an, dass diese neue Zeitachse sich an der «Block»-Achse ausrichten soll (in den meisten Sprachen die vertikale y-Achse).

animation-name: scroll-indicator;
animation-timeline: --scroll-timeline;Code-Sprache: CSS (css)

Die Scroll Shadows liegen auf den zwei ::before– und ::after-Pseudo-Elementen, die mit position: sticky jeweils am Kopf- und Fuss-Ende des Scroll-Containers platziert werden. Ihnen weisen wir eine Animation zu namens scroll-indicator, die von unserer eben definierten Zeitachse namens --scroll-timeline kontrolliert wird.

@keyframes scroll-indicator {
  0% { 
    opacity: 0; 
  }
  15% { 
    opacity: 1; 
  }
  100% { 
    /* `animation-fill-mode` funktioniert hier nicht, deshalb legen wir den End-Zustand explizit fest. */
    opacity: 1;
  } 
}Code-Sprache: CSS (css)

Die Animation tut nichts anderes, als die Pseudo-Elemente ein- und auszublenden. Bei 0 % Scroll-Fortschritt haben sie 0 % Opazität – sind also unsichtbar – und bei 100 % Scroll-Fortschritt auch 100 % Opazität, sodass man sie wieder sehen kann. Das ist schon der ganze Zauber.

Weil aber nicht beide Scroll Shadows gleichzeitig ausgeblendet werden sollen, bekommt der untere Schatten im ::after-Pseudo-Element die Eigenschaft animation-direction: reverse. Und da wir auch nicht wollen, dass die Schatten Interaktionen mit dem Inhalt erschweren, weisen wir ihnen noch pointer-events: none zu.

💡

Bei einer klassischen, zeitbasierten Animation würde animation-fill-mode: both dafür sorgen, dass die Animation ihren End-Zustand beibehält, wenn sie abgeschlossen ist. Das scheint mit animation-timeline-Animationen nicht zu funktionieren, weshalb wir den 100%-Zustand fest definieren müssen …

Und horizontal betrachtet?

Den gleichen Effekt kann man natürlich auch für horizontale Scroll-Container nutzen. Im folgenden Beispiel verwende ich wieder die «alte» Methode mit background-attachment.

Beispiel als Video

Vollständiger CSS-Code
@media ( prefers-reduced-motion: no-preference ) {
  .scroll-container {
    background:
      linear-gradient(
        90deg, /* In die Vertikale gedreht */
        var( --white ) var( --scroll-shadow-size ),
        transparent
      ) center left, /* `left` statt `top` */

      linear-gradient(
        -90deg, /* In die Vertikale gedreht */
        var( --white ) var( --scroll-shadow-size ),
        transparent
      ) center right, /* `right` statt `bottom` */

      linear-gradient(
        90deg, /* In die Vertikale gedreht */
        color-mix( in srgb, var( --black ), transparent 50% ),
        transparent
      ) center left, /* `left` statt `top` */

      linear-gradient(
        -90deg, /* In die Vertikale gedreht */
        color-mix( in srgb, var( --black ), transparent 50% ),
        transparent
      ) center right, /* `right` statt `bottom` */

      var(--white);
    background-size:
      /* Breiten und Höhen getauscht */
      calc( 2 * var( --scroll-shadow-size ) ) 100%,
      calc( 2 * var( --scroll-shadow-size ) ) 100%,
      var( --scroll-shadow-size ) 100%,
      var( --scroll-shadow-size ) 100%;
    background-attachment: local, local, scroll, scroll;
    background-repeat: no-repeat;
  }
}
Code-Sprache: CSS (css)

Und natürlich stossen wir erneut auf unser bekanntes Problem, sobald Inhalte mit Hintergründen ins Spiel kommen.

Beispiel als Video

Bis zur Unendlichkeit, und …

Keine Sorge – unsere neue Methode mit animation-timeline funktioniert auch für horizontale Scroll-Container. Dazu stellen wir scroll-timeline-axis einfach um von block auf inline.

Beispiel als Video

Vollständiger CSS-Code
@media ( prefers-reduced-motion: no-preference ) {
  @supports ( ( animation-timeline: scroll() ) ) {
    .scroll-container {
      --scroll-indicator-size: 1rem;

      position: relative;
      overflow-y: clip; /* Das ist neu! */
      scroll-timeline-name: --scroll-timeline;
      scroll-timeline-axis: inline;

      &::before,
      &::after {
        content: "";
        position: sticky;
        display: block;
        width: var( --scroll-shadow-size ); /* `width` statt `height` */
        height: calc( infinity * 1px ); /* Hier wird es etwas wild … */
        opacity: 0;
        pointer-events: none;
        animation-name: scroll-indicator;
        animation-timeline: --scroll-timeline;
      }

      &::before {
        left: 0; /* `left` statt `top` */'
        margin-block-end: calc( -infinity * 1px ); /* Ich erklär's gleich … */
        background:
          linear-gradient(
            90deg, /* In die Vertikale gedreht */
            color-mix( in srgb, var( --black ), transparent 50% ),
            transparent
          );
      }

      &::after {
        animation-direction: reverse;
        left: calc( 100% - var( --scroll-shadow-size ) ); /* Statt `bottom: 0` */
        margin-block-start: calc( -infinity * 1px ); /* Vertraut mir!!! */
        background:
          linear-gradient(
            -90deg, /* In die Vertikale gedreht */
            color-mix( in srgb, var( --black ), transparent 50% ),
            transparent
          );
      }    
    }
  }
}
Code-Sprache: CSS (css)

Wenn wir uns auf der x-Achse bewegen, brauchen wir aber noch einen Kniff: Der Browser weiss zwar immer, was width: 100% ist, height: 100% bewirkt aber nichts ohne fest definierte Höhe. Deshalb können wir die Pseudo-Elemente nicht so einfach auf die richtige Größe strecken. Jetzt müssen wir kreativ werden.

.scroll-container {
  overflow-y: clip;

  &::before,
  &::after {
    height: calc( infinity * 1px );
  }

  &::before {
    margin-block-end: calc( -infinity * 1px );
  }

  &::after {
    margin-block-start: calc( -infinity * 1px );
  }
}Code-Sprache: CSS (css)

Richtig gesehen: Wir geben unseren Pseudo-Elementen eine unendliche Höhe. Und dann schieben wir sie noch in den sichtbaren Bereich mit negativen unendlichen Margins. So decken sie immer den ganzen Scroll-Container ab – klingt abgedreht, aber erfüllt seinen Zweck.

Unendliche Möglichkeiten

Das ist das erste Mal, dass ich animation-timeline in der Praxis eingesetzt habe. Genau solche Herausforderungen machen mir am meisten Spass in meiner Arbeit als Frontend-Developer: Ich entdecke eine neue CSS-Eigenschaft, experimentiere mit ihr und kann meine Probleme plötzlich auf eine clevere Art lösen.

Diese Kreativität hält geistig fit und ist eine schöne Erfrischung, wenn man mal wieder zu lange auf Datenbanken und Deployments gestarrt hat. Hört also niemals auf, herumzuspielen – wer weiss, was ihr in den Schatten noch entdeckt …