Simulating Drop Shadows with the CSS Paint API

Avatar of Steve Fulghum
Steve Fulghum am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Ask a hundred front-end developers, and most, if not all, of them will have used the box-shadow property in their careers. Shadows are enduringly popular, and can add an elegant, subtle effect if used properly. But shadows occupy a strange place in the CSS box model. They have no effect on an element’s width and height, and are readily clipped if overflow on a parent (or grandparent) element is hidden.

We can work around this with standard CSS in a few different ways. But, now that some of the CSS Houdini specifications are being implemented in browsers, there are tantalizing new options. The CSS Paint API, for example, allows developers to generate images programmatically at run time. Let’s look at how we can use this to paint a complex shadow within a border image.

Ein kurzer Crashkurs zu Houdini

Sie haben vielleicht von einer neuen CSS-Technologie gehört, die mit dem eingängigen Namen Houdini auf die Plattform kommt. Houdini verspricht, einen besseren Zugriff darauf zu ermöglichen, wie der Browser die Seite rendert. Wie MDN schreibt, ist es "eine Reihe von Low-Level-APIs, die Teile der CSS-Engine offenlegen und Entwicklern die Möglichkeit geben, CSS zu erweitern, indem sie sich in den Styling- und Layoutprozess der Rendering-Engine eines Browsers einklinken."

Die CSS Paint API

Die CSS Paint API ist eine der ersten dieser APIs, die in Browsern ankommt. Es handelt sich um eine W3C Candidate Recommendation. Dies ist die Phase, in der Spezifikationen mit der Implementierung beginnen. Sie ist derzeit für die allgemeine Nutzung in Chrome und Edge verfügbar, während Safari sie hinter einem Flag hat und Firefox sie als „prototypfähig“ einstuft. Es gibt ein Polyfill für nicht unterstützte Browser, obwohl es in IE11 nicht funktioniert.

Obwohl die CSS Paint API in Chromium aktiviert ist, ist die Übergabe von Argumenten an die paint()-Funktion immer noch hinter einem Flag. Sie müssen experimentelle WebplattformenFeatures für den Moment aktivieren. Diese Beispiele funktionieren im Moment leider möglicherweise nicht in Ihrem Browser Ihrer Wahl. Betrachten Sie sie als Beispiel für zukünftige Entwicklungen, die noch nicht für den Produktionseinsatz bereit sind.

Der Ansatz

Wir werden ein Bild mit einem Schatten generieren und es dann für ein border-image verwenden... hä? Nun, schauen wir genauer hin.

Wie bereits erwähnt, fügen Schatten einem Element keine Breite oder Höhe hinzu, sondern breiten sich von seiner Bounding Box aus. In den meisten Fällen ist das kein Problem, aber diese Schatten sind anfällig für Clipping. Eine gängige Umgehung ist, einen gewissen Abstand mit Padding oder Margin zu schaffen.

Wir werden den Schatten direkt in das Element einbauen, indem wir ihn in den border-image-Bereich malen. Das hat einige entscheidende Vorteile:

  1. border-width trägt zur Gesamtbreite des Elements bei
  2. Inhalte werden nicht in den Randbereich überlaufen und den Schatten überlagern
  3. Padding benötigt keine zusätzliche Breite, um Schatten und Inhalt aufzunehmen
  4. Randabstände um das Element herum stören die Geschwister dieses Elements nicht

Von der oben genannten Gruppe von hundert Entwicklern, die box-shadow verwendet haben, haben es wahrscheinlich nur wenige border-image verwendet. Es ist eine eigenartige Eigenschaft. Im Wesentlichen nimmt sie ein Bild, teilt es in neun Teile und platziert sie in den vier Ecken, den Seiten und (optional) der Mitte. Mehr über die Funktionsweise erfahren Sie in Nora Browns Artikel.

Die CSS Paint API kümmert sich um die Schwerstarbeit der Bilderzeugung. Wir werden ein Modul dafür erstellen, das ihr sagt, wie sie eine Reihe von Schatten übereinander legen soll. Dieses Bild wird dann von border-image verwendet.

Dies sind die Schritte, die wir unternehmen werden:

  1. Einrichten von HTML und CSS für das Element, in das wir malen wollen
  2. Erstellen eines Moduls, das das Bild zeichnet
  3. Laden des Moduls in einen Paint Worklet
  4. Aufrufen des Worklets in CSS mit der neuen paint()-Funktion

Einrichten der Leinwand

Sie werden die Begriffe canvas und Leinwand hier und in anderen Ressourcen zur CSS Paint API mehrmals hören. Wenn Ihnen der Begriff bekannt vorkommt, haben Sie Recht. Die API funktioniert ähnlich wie das HTML-Element <canvas>.

Zuerst müssen wir die Leinwand einrichten, auf der die API malen wird. Dieser Bereich hat die gleichen Abmessungen wie das Element, das die Paint-Funktion aufruft. Machen wir ein 300x300 div.

<section>
  <div class="foo"></div>
</section>

Und die Styles

.foo {
  border: 15px solid #efefef;
  box-sizing: border-box;
  height: 300px;
  width: 300px;
}

Erstellen der Paint-Klasse

HTTPS ist erforderlich für jegliche JavaScript-Worklets, einschließlich Paint Worklets. Sie können es überhaupt nicht verwenden, wenn Sie Ihre Inhalte über HTTP bereitstellen.

Der zweite Schritt ist die Erstellung des Moduls, das in das Worklet geladen wird – eine einfache Datei mit der Funktion registerPaint(). Diese Funktion nimmt zwei Argumente entgegen: den Namen des Worklets und eine Klasse, die die Malogik enthält. Um Ordnung zu halten, verwenden wir eine anonyme Klasse.

registerPaint(
  "shadow",
  class {}
);

In unserem Fall benötigt die Klasse zwei Attribute, inputProperties und inputArguments, und eine Methode, paint().

registerPaint(
  "shadow",
  class {
    static get inputProperties() {
      return [];
    }
    static get inputArguments() {
      return [];
    }
    paint(context, size, props, args) {}
  }
);

inputProperties und inputArguments sind optional, aber notwendig, um Daten in die Klasse zu übergeben.

Hinzufügen von Eingabeeigenschaften

Wir müssen dem Worklet mitteilen, welche CSS-Eigenschaften vom Ziel-Element mit inputProperties übernommen werden sollen. Es ist ein Getter, der ein Array von Strings zurückgibt.

In diesem Array listen wir sowohl die benutzerdefinierten als auch die Standardeigenschaften auf, die die Klasse benötigt: --shadow-colors, background-color und border-top-width. Achten Sie besonders darauf, wie wir Nicht-Kurzschreibweisen verwenden.

static get inputProperties() {
  return ["--shadow-colors", "background-color", "border-top-width"];
}

Der Einfachheit halber gehen wir hier davon aus, dass der Rand auf allen Seiten gleich ist.

Hinzufügen von Argumenten

Derzeit sind inputArguments noch hinter einem Flag, daher müssen experimentelle Features aktiviert sein. Ohne sie verwenden Sie stattdessen inputProperties und benutzerdefinierte Eigenschaften.

Wir übergeben auch Argumente an das Paint-Modul mit inputArguments. Auf den ersten Blick scheinen sie überflüssig gegenüber inputProperties, aber es gibt feine Unterschiede, wie die beiden verwendet werden.

Wenn die Paint-Funktion in der Stylesheet aufgerufen wird, werden inputArguments explizit im paint()-Aufruf übergeben. Dies gibt ihnen einen Vorteil gegenüber inputProperties, die Eigenschaften abhören könnten, die von anderen Skripten oder Stilen geändert werden. Wenn Sie beispielsweise eine benutzerdefinierte Eigenschaft verwenden, die auf :root gesetzt ist und sich ändert, kann dies die Ausgabe beeinträchtigen.

Der zweite wichtige Unterschied bei inputArguments, der nicht intuitiv ist, ist, dass sie nicht benannt sind. Stattdessen werden sie im Paint-Methode als Elemente eines Arrays referenziert. Wenn wir inputArguments mitteilen, was es empfängt, geben wir ihm eigentlich den Typ des Arguments.

Die Klasse shadow benötigt drei Argumente: eines für die X-Positionen, eines für die Y-Positionen und eines für die Weichzeichnungen. Wir richten das als drei durch Leerzeichen getrennte Listen von ganzen Zahlen ein.

Jeder, der eine benutzerdefinierte Eigenschaft registriert hat, erkennt vielleicht die Syntax. In unserem Fall bedeutet das Schlüsselwort <integer> jede ganze Zahl, während + eine durch Leerzeichen getrennte Liste kennzeichnet.

static get inputArguments() {
  return ["<integer>+", "<integer>+", "<integer>+"];
}

Um inputProperties anstelle von inputArguments zu verwenden, könnten Sie benutzerdefinierte Eigenschaften direkt auf dem Element setzen und sie abhören. Die Benennung wäre entscheidend, um sicherzustellen, dass geerbte benutzerdefinierte Eigenschaften von anderswo nicht durchsickern.

Hinzufügen der Paint-Methode

Nachdem wir die Eingaben haben, ist es an der Zeit, die Paint-Methode einzurichten.

Ein Schlüsselkonzept für paint() ist das context-Objekt. Es ist ähnlich und funktioniert ähnlich wie der Kontext des HTML-Elements <canvas>, wenn auch mit einigen kleinen Unterschieden. Derzeit können Sie keine Pixel vom Canvas lesen (aus Sicherheitsgründen) oder Text rendern (es gibt eine kurze Erklärung dafür in diesem GitHub-Thread).

Die Methode paint() hat vier implizite Parameter:

  1. Das Kontextobjekt
  2. Geometrie (ein Objekt mit Breite und Höhe)
  3. Eigenschaften (eine Map von inputProperties)
  4. Argumente (die aus der Stylesheet übergebenen Argumente)
paint(ctx, geom, props, args) {}

Abrufen der Abmessungen

Das geometry-Objekt kennt die Größe des Elements, aber wir müssen die 30 Pixel des gesamten Randes auf der X- und Y-Achse berücksichtigen.

const width = (geom.width - borderWidth * 2);
const height = (geom.height - borderWidth * 2);

Verwenden von Eigenschaften und Argumenten

Properties und arguments enthalten die aufgelösten Daten von inputProperties und inputArguments. Properties kommen als map-ähnliches Objekt an, und wir können Werte mit get() und getAll() abrufen.

const borderWidth = props.get("border-top-width").value;
const shadowColors = props.getAll("--shadow-colors");

get() gibt einen einzelnen Wert zurück, während getAll() ein Array zurückgibt.

--shadow-colors wird eine durch Leerzeichen getrennte Liste von Farben sein, die in ein Array übernommen werden kann. Wir werden dies später beim Browser registrieren, damit er weiß, was er erwarten kann.

Wir müssen auch angeben, mit welcher Farbe das Rechteck gefüllt werden soll. Es wird die gleiche Hintergrundfarbe wie das Element verwenden.

ctx.fillStyle = props.get("background-color").toString();

Wie bereits erwähnt, kommen Argumente als Array in das Modul und wir referenzieren sie per Index. Sie sind derzeit vom Typ CSSStyleValue – machen wir es einfacher, sie zu durchlaufen.

  1. Konvertieren Sie den CSSStyleValue mit seiner toString()-Methode in einen String.
  2. Teilen Sie das Ergebnis mit einem Regex durch Leerzeichen.
const blurArray = args[2].toString().split(/\s+/);
const xArray = args[0].toString().split(/\s+/);
const yArray = args[1].toString().split(/\s+/);
// e.g. ‘1 2 3’ -> [‘1’, ‘2’, ‘3’]

Zeichnen der Schatten

Nachdem wir nun die Abmessungen und Eigenschaften haben, ist es Zeit, etwas zu zeichnen! Da wir für jeden Eintrag in shadowColors einen Schatten benötigen, werden wir sie durchlaufen. Beginnen wir mit einer forEach()-Schleife.

shadowColors.forEach((shadowColor, index) => { 
});

Mit dem Index des Arrays greifen wir auf die übereinstimmenden Werte aus den X-, Y- und Blur-Argumenten zu.

shadowColors.forEach((shadowColor, index) => {
  ctx.shadowOffsetX = xArray[index];
  ctx.shadowOffsetY = yArray[index];
  ctx.shadowBlur = blurArray[index];
  ctx.shadowColor = shadowColor.toString();
});

Schließlich verwenden wir die Methode fillRect(), um auf die Leinwand zu zeichnen. Sie nimmt vier Argumente entgegen: X-Position, Y-Position, Breite und Höhe. Für die Positionsangaben verwenden wir border-width aus inputProperties; so wird das border-image so zugeschnitten, dass es nur den Schatten um das Rechteck herum enthält.

shadowColors.forEach((shadowColor, index) => {
  ctx.shadowOffsetX = xArray[index];
  ctx.shadowOffsetY = yArray[index];
  ctx.shadowBlur = blurArray[index];
  ctx.shadowColor = shadowColor.toString();

  ctx.fillRect(borderWidth, borderWidth, width, height);
});

Diese Technik kann auch mit einem Canvas-Drop-Shadow-Filter und einem einzigen Rechteck umgesetzt werden. Sie ist unterstützt in Chrome, Edge und Firefox, aber nicht in Safari. Siehe ein fertiges Beispiel auf CodePen.

Fast geschafft! Es sind nur noch ein paar Schritte, um alles zu verdrahten.

Registrieren des Paint-Moduls

Zuerst müssen wir unser Modul als Paint Worklet beim Browser registrieren. Dies geschieht wieder in unserer Haupt-JavaScript-Datei.

CSS.paintWorklet.addModule("https://codepen.io/steve_fulghum/pen/bGevbzm.js");
https://codepen.io/steve_fulghum/pen/BazexJX

Registrieren von benutzerdefinierten Eigenschaften

Etwas anderes, das wir tun sollten, aber nicht unbedingt notwendig ist, ist, dem Browser durch Registrierung etwas mehr über unsere benutzerdefinierten Eigenschaften zu erzählen.

Die Registrierung von Eigenschaften gibt ihnen einen Typ. Wir wollen, dass der Browser weiß, dass --shadow-colors eine Liste von tatsächlichen Farben ist und nicht nur ein String.

Wenn Sie Browser ansprechen müssen, die die Properties and Values API nicht unterstützen, verzweifeln Sie nicht! Benutzerdefinierte Eigenschaften können immer noch vom Paint-Modul gelesen werden, auch wenn sie nicht registriert sind. Sie werden jedoch als ungeparste Werte behandelt, die im Wesentlichen Strings sind. Sie müssen Ihre eigene Parsing-Logik hinzufügen.

Wie addModule() wird dies zur Haupt-JavaScript-Datei hinzugefügt.

CSS.registerProperty({
  name: "--shadow-colors",
  syntax: "<color>+",
  initialValue: "black",
  inherits: false
});

Sie können auch @property in Ihrer Stylesheet verwenden, um Eigenschaften zu registrieren. Eine kurze Erklärung finden Sie auf MDN.

Anwendung auf border-image

Unser Worklet ist nun beim Browser registriert, und wir können die Paint-Methode in unserer Haupt-CSS-Datei aufrufen, um sie anstelle einer Bild-URL zu verwenden.

border-image-source: paint(shadow, 0 0 0, 8 2 1, 8 5 3) 15;
border-image-slice: 15;

Dies sind einheitenlose Werte. Da wir ein 1:1-Bild zeichnen, entsprechen sie Pixeln.

Anpassung an Anzeigeverhältnisse

Wir sind fast fertig, aber es gibt noch ein Problem zu lösen.

Bei einigen von Ihnen sehen die Dinge vielleicht nicht ganz so aus, wie erwartet. Ich wette, Sie haben sich den schicken High-DPI-Monitor gegönnt, nicht wahr? Wir sind auf ein Problem mit dem Gerätepixelverhältnis gestoßen. Die an das Paint Worklet übergebenen Abmessungen wurden nicht skaliert, um übereinzustimmen.

Anstatt jeden Wert manuell zu skalieren, ist eine einfache Lösung, den Wert für border-image-slice zu multiplizieren. Hier ist, wie Sie es für die richtige geräteübergreifende Anzeige tun.

Zuerst registrieren wir eine neue benutzerdefinierte Eigenschaft für CSS, die window.devicePixelRatio verfügbar macht.

CSS.registerProperty({
  name: "--device-pixel-ratio",
  syntax: "<number>",
  initialValue: window.devicePixelRatio,
  inherits: true
});

Da wir die Eigenschaft registrieren und ihr einen Anfangswert geben, müssen wir sie nicht auf :root setzen, da inherit: true sie an alle Elemente weitergibt.

Und schließlich multiplizieren wir unseren Wert für border-image-slice mit calc().

.foo {
  border-image-slice: calc(15 * var(--device-pixel-ratio));
}

Es ist wichtig zu beachten, dass Paint Worklets auch standardmäßig Zugriff auf den devicePixelRatio-Wert haben. Sie können ihn einfach in der Klasse referenzieren, z. B. console.log(devicePixelRatio).

Fertig

Puh! Wir sollten nun ein korrekt skaliertes Bild haben, das im Bereich des Rahmens gemalt wird!

Live demo (best viewed in Chrome and Edge)
Live-Demo (am besten in Chrome und Edge angezeigt)

Bonus: Anwendung auf ein Hintergrundbild

Ich würde mich schuldig fühlen, wenn ich nicht auch eine Lösung zeigen würde, die background-image anstelle von border-image verwendet. Das ist mit nur wenigen Änderungen an dem gerade geschriebenen Modul einfach zu machen.

Da kein border-width-Wert zur Verfügung steht, machen wir diesen zu einer benutzerdefinierten Eigenschaft.

CSS.registerProperty({
  name: "--shadow-area-width",
  syntax: "<integer>",
  initialValue: "0",
  inherits: false
});

Wir müssen auch die Hintergrundfarbe mit einer benutzerdefinierten Eigenschaft steuern. Da wir innerhalb der Inhaltsbox malen, wird das Setzen einer tatsächlichen background-color immer noch hinter dem Hintergrundbild angezeigt.

CSS.registerProperty({
  name: "--shadow-rectangle-fill",
  syntax: "<color>",
  initialValue: "#fff",
  inherits: false
});

Dann setzen wir sie auf .foo.

.foo {
  --shadow-area-width: 15;
  --shadow-rectangle-fill: #efefef;
}

Diesmal wird paint() auf background-image gesetzt, wobei die gleichen Argumente wie bei border-image verwendet werden.

.foo {
  --shadow-area-width: 15;
  --shadow-rectangle-fill: #efefef;
  background-image: paint(shadow, 0 0 0, 8 2 1, 8 5 3);
}

Wie erwartet wird dies den Schatten im Hintergrund malen. Da Hintergrundbilder jedoch in die Padding-Box reichen, müssen wir das padding anpassen, damit der Text nicht überlappt.

.foo {
  --shadow-area-width: 15;
  --shadow-rectangle-fill: #efefef;
  background-image: paint(shadow, 0 0 0, 8 2 1, 8 5 3);
  padding: 15px;
}

Fallback-Optionen

Wie wir alle wissen, leben wir nicht in einer Welt, in der jeder den gleichen Browser benutzt oder Zugang zum Neuesten und Besten hat. Um sicherzustellen, dass sie kein fehlerhaftes Layout erhalten, betrachten wir einige Fallbacks.

Padding-Fix

Padding auf dem Elternelement komprimiert die Inhaltsbox, um Schatten, die von seinen Kindern ausgehen, zu berücksichtigen.

section.parent {
  padding: 6px; /* size of shadow on child */
}

Margin-Fix

Ränder auf Kindelementen können zur Abstandsgestaltung verwendet werden, um Schatten von ihren clippinggebenden Eltern fernzuhalten.

div.child {
  margin: 6px; /* size of shadow on self */
}

Kombination von border-image mit einem radialen Gradienten

Dies ist etwas abseits des üblichen Pfades als Padding oder Margins, aber es hat eine hervorragende Browser-Unterstützung. CSS erlaubt die Verwendung von Verläufen anstelle von Bildern, sodass wir einen innerhalb eines border-image verwenden können, genau wie wir es mit paint() getan haben. Dies könnte eine großartige Option als Fallback für die Paint-API-Lösung sein, solange das Design nicht genau den gleichen Schatten erfordert.

Verläufe können schwierig und knifflig sein, aber Geoff Graham hat einen tollen Artikel über ihre Verwendung.

div {
  border: 6px solid;
  border-image: radial-gradient(
    white,
    #aaa 0%,
    #fff 80%,
    transparent 100%
  )
  25%;
}

Ein versetztes Pseudo-Element

Wenn Sie mit zusätzlichem Markup und CSS-Positionierung kein Problem haben und einen exakten Schatten benötigen, können Sie auch einInset-Pseudo-Element verwenden. Vorsicht mit z-index! Abhängig vom Kontext muss es möglicherweise angepasst werden.

.foo {
  box-sizing: border-box;
  position: relative;
  width: 300px;
  height: 300px;
  padding: 15px;
}

.foo::before {
  background: #fff;
  bottom: 15px;
  box-shadow: 0px 2px 8px 2px #333;
  content: "";
  display: block;
  left: 15px;
  position: absolute;
  right: 15px;
  top: 15px;
  z-index: -1;
}

Abschließende Gedanken

Und das, Leute, ist, wie Sie die CSS Paint API verwenden können, um genau das Bild zu malen, das Sie brauchen. Ist es das Erste, woran Sie bei Ihrem nächsten Projekt denken werden? Das müssen Sie entscheiden. Die Browser-Unterstützung ist noch ausstehend, aber auf dem Vormarsch.

Fairerweise muss man sagen, dass es weit mehr Komplexität hinzufügen kann, als ein einfaches Problem erfordert. Wenn Sie jedoch eine Situation haben, die Pixel erfordert, die genau dort platziert werden, wo Sie sie haben wollen, ist die CSS Paint API ein mächtiges Werkzeug.

Am spannendsten ist jedoch die Möglichkeit, die sie Designern und Entwicklern bietet. Das Zeichnen von Schatten ist nur ein kleines Beispiel dafür, was die API leisten kann. Mit etwas Vorstellungskraft und Einfallsreichtum sind alle Arten von neuen Designs und Interaktionen möglich.

Weitere Lektüre