Styling & Structure: CSS Templates
In this section we will demonstrate how to style the my-button
component, covering the following items:
- Basic Styles,
- Composing Styles,
- Partial CSS,
- Dynamic Behavior with CSSDirective,
- Shadow DOM Styling,
- Slotted Content,
- Style Lifecycle Management,
- Hiding Undefined Elements
Basic Styles
In the previous sections and examples you might have noticed a css
tag we imported from @genesislcap/web-core
along with html
.
Quick reminder on how to create a custom element using @customElement
:
import {
css, customElement, html} from "@genesislcap/web-core";
@customElement({
name: "my-button",
template,
styles // focusing on styles,
})
So far we have seen how we can attach styles using the styles
property in the @customElement
decorator. But let's dive deeper and explore how we can define styles:
Defining Styles:
In the example below we show how the css
tagged template helper allows creating and re-using CSS for custom elements.
These styles are encapsulated in the Shadow DOM and attached via the styles
property in the @customElement
decorator.
import {
html,
css,
customElement,
GenesisElement,
} from "@genesislcap/web-core";
const styles = css`
:host { /* The :host selector applies styles to the custom element itself (<my-button>).*/
display: inline-block;
padding: 10px;
background-color: gray;
}
:host([primary]) { /* :host([primary]): Styles the element with blue background if it has the primary attribute. */
background-color: blue;
}
:host([disabled]) { /* :host([disabled]): Styles the element with reduced opacity and disabled interaction if the disabled attribute is present.*/
opacity: 0.5;
pointer-events: none;
}
`;
@customElement({
name: "my-button",
template: html<MyButton>`<button>${(x) => x.label}</button>`,
styles,
})
export class MyButton extends GenesisElement {
@attr primary: boolean = false;
@attr disabled: boolean = false;
}
Even if the primary
and disabled
properties default to false, adding the attributes in the HTML template will apply the corresponding styles. Boolean attributes are present or absent.
Usage in HTML
<my-button> label="Click Me" primary></my-button>
<my-button> label="Disabled" disabled></my-button>
- The first button will have a blue background (
primary
attribute applied).- The second button will have reduced opacity and disabled pointer events (
disabled
attribute applied).
In the example above we have:
- Used the
styles
property in the@customElement
decorator.- Ensured
styles
are encapsulated within the Shadow DOM.- Used
:host
and:host([attribute])
to apply styles based on the custom element's attributes.
Composing Styles
We can also compose reusable styles and utility styles into our component. This approach encourages consistency across components and reduces duplication by enabling shared styles.
Let's say we want to compose our own utility style (called normalize), that ensures consistent baseline styles across browsers. This can include setting consistent 'margin', 'padding', 'line-height', and other foundational properties.
// normalize.ts
import { css } from "@genesislcap/web-core";
export const normalize = css`
html, body, * {
margin: 0;
padding: 0;
box-sizing: border-box;
}
button {
all: unset;
cursor: pointer;
}
`;
Here's how we can now use normalize utlity along with additional reusable style blocks to style my-button
component.
import {
css,
html,
customElement,
GenesisElement,
} from "@genesislcap/web-core";
import { normalize } from "./normalize"; // Import normalize styles
// Define reusable base button styles
const buttonBaseStyles = css`
button {
font-family: Arial, sans-serif;
font-size: 16px;
padding: 10px 20px;
border-radius: 4px;
text-align: center;
border: 1px solid transparent;
}
`;
@customElement({
name: "my-button",
template: html<MyButton>` <button>${(x) => x.label}</button> `,
styles: css`
${normalize} /* Include normalize styles */
${buttonBaseStyles} /* Include reusable button styles */
/*normalize and buttonBaseStyles combined for consistent design across components*/
:host {
display: inline-block;
}
button {
background-color: blue;
color: white;
transition: background-color 0.3s ease-in-out;
}
button:hover {
background-color: darkblue;
}
button:active {
background-color: navy;
}
`,
})
export class MyButton extends GenesisElement {
label: string = "Click Me";
}
In the example above we have:
- Seen how to reuse and combine shared style blocks like normalize or utility styles.
- Combined multiple styles using the
css
helper.
Partial CSS
In addition to reusing styles dynamically, we can also organize styles into separate files and this is when 'partial CSS' comes in handy.
To achieve this we can leverage cssPartial
. See the example below:
import { css, cssPartial } from "@genesislcap/web-core";
const partial = cssPartial`padding: 10px 20px;`; // We've created partial styles using cssPartial
const styles = css`
:host {
${partial} /* we can attach partial into host */
}
`;
@customElement({
name: "my-button",
template: html<MyButton>`<button>${(x) => x.label}</button>`,
styles: styles,
})
In the example above we have:
- Defined reusable snippets of CSS using
cssPartial
.- Reused blocks of CSS properties that aren’t standalone styles.
CSSDirective
A CSSDirective
allows us to create dynamic CSS styles and behaviors.
Instead of using static styles, you can programmatically control how the styles are applied to your component,
making it powerful for things like animations, dynamic layouts, or user interactions.
We can achieve this in a few simple steps:
Import the required Modules:
import { CSSDirective } from "@genesislcap/web-core";
Define the Custom Directive
class RandomWidth extends CSSDirective {
private property = "--button-width";
}
- A new class
RandomWidth
is created, extendingCSSDirective
. This makes it a directive that can generate styles dynamically.private property = "--button-width"
defines a CSS custom property (--button-width
) that will hold the dynamically generatedwidth
value.
Generate CSS Dynamically
createCSS() {
return `width: var(${this.property});`; // value of --button-width
}
- The
createCSS()
method specifies what CSS should be added to the element.- Here, it generates a rule to set the
width
of the element based on the value of the--button-width
custom property (which will be dynamically updated).
Define behavior
createBehavior() {
return {
bind(el) {
el.style.setProperty(this.property, `${Math.random() * 100 + 100}px`);
},
unbind(el) {
el.style.removeProperty(this.property);
},
};
}
-
The
createBehavior()
method defines the dynamic behavior of the directive:bind(el)
: When the element is connected to the DOM, it sets the--button-width
property to a random value between 100px and 200px.unbind(el)
: When the element is disconnected, it removes the--button-width
property to clean up.
Attach the Directive to Styles
import {
css,
html,
customElement,
GenesisElement,
} from "@genesislcap/web-core";
@customElement({
name: "my-button",
template: html<MyButton>` <button>${(x) => x.label}</button> `,
styles: css`
button {
${new RandomWidth()}
background-color: blue;
color: white;
}
`,
})
export class MyButton extends GenesisElement {
label: string = "Click Me";
}
- The
RandomWidth
directive is used in the styles property for themy-button
component.
- When the button is rendered:
- The
RandomWidth
directive dynamically sets the width of the button using the random value generated bybind()
.- The button also gets a blue background and white text from the other static styles.
When the code runs:
- When the
my-button
component is created and added to the DOM, thebind()
method runs.- A random width is calculated (e.g., 150px) and applied to the
--button-width
property.- The
createCSS()
method uses this property to set the button's width.- The button element will have a random width (e.g., 150px) and the other static styles like blue background and white text.
- If the element is removed from the DOM, the
unbind()
method is called, which removes the--button-width
property, ensuring there are no leftover styles.
In this section we have applied dynamic styles by:
- Adding behavior-driven styles to custom elements using two key methods:
createCSS()
: Generates CSS dynamically.createBehavior()
: Attaches dynamic behavior to an element.
Shadow DOM Styling
Shadow DOM encapsulation ensures styles do not affect the global DOM. Meaning they apply only to your custom element and do not "leak" into the global styles. Conversely, styles from the global scope cannot accidentally modify the appearance of your custom element.
Some considerations for Shadow DOM Styling
- By default, custom elements behave as inline elements. You might want to define a specific
display
property likeinline-block
orblock
to control their layout behavior.- Setting
contain: content
improves performance by signaling to the browser that the element's layout, painting, and size calculations are self-contained. This ensures the browser doesn't unnecessarily re-calculate styles or layout changes for the entire document if something inside the component changes.- Adding support for the
hidden
attribute ensures your component properly respects thedisplay: none
behavior whenhidden
is applied. This is a common practice for ensuring that components behave predictably.
The :host
selector allows us to apply styles directly to our custom element.
import {
css,
html,
customElement,
GenesisElement,
} from "@genesislcap/web-core";
const styles = css`
/* Encapsulation and default display */
:host {
display: inline-block; /* Ensure consistent layout behavior */
contain: content; /* Optimize performance */
}
/* Support for the "hidden" attribute */
:host([hidden]) {
display: none; /* Completely remove the component from the visual flow */
}
/* Styles for the internal button */
button {
background-color: blue;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
`;
@customElement({
name: "my-button",
template: html<MyButton>`
<button><slot></slot></button>
<!-- Slot allows flexible content -->
`,
styles,
})
export class MyButton extends GenesisElement {}
Key Points:
:host
ensures that styles likedisplay
andcontain
apply only to the<my-button>
element.
- This prevents global CSS rules from interfering with the component and vice versa.
- When
<my-button hidden>
is used, the component is visually removed usingdisplay: none
.
- This is a common convention for enabling/disabling components.
- The button inside the Shadow DOM is styled with "padding", "background color", and "rounded corners". These styles are scoped to the Shadow DOM and won't affect other buttons outside the
my-button
component.
Slotted Content
A slot is a placeholder where users of the component can insert their own content. This allows components to be flexible and reusable.
For example, if my-button
component has a slot
, you can pass content like a label
or an icon
into the button when using it.
The ::slotted()
CSS element lets you style content that is projected into a slot in your component's Shadow DOM.
However, you can only style direct children of the slot (not descendants of those children).
@customElement({
name: "my-button",
template: html<MyButton>`
<button><slot></slot></button>
// Slot for user content
`,
styles: css`
button {
background-color: blue; /* Styles for the button */
color: white; /* Button text color */
}
::slotted(span) {
color: yellow; /* Styles for <span> elements inside the slot */
font-weight: bold; /* Make slotted <span> text bold */
}
`,
})
export class MyButton extends GenesisElement {}
In this case, the <span>
inside the slot will have yellow text and be bold.
Styles applied to button
and ::slotted(span)
are scoped to my-button
Shadow DOM and won’t affect buttons or spans elsewhere in the document.
Usage in HTML
<my-button>
<button style="background-color: blue; color: white;">
<span style="color: yellow; font-weight: bold;">Click Me</span>
</button>
</my-button>
Key Limitations
::slotted()
can only style the immediate children of the <slot>
. If the content inside the slot has nested elements, those cannot be styled directly:
Styles defined in the Shadow DOM (like button ) do not affect the slotted content directly. Slotted content is styled using the ::slotted()
selector.
<my-button>
<div>
<span>Cannot style this span</span>
</div>
</my-button>
Style Lifecycle Management
Styles are typically defined when a custom element is created and attached to the DOM. However, in some cases, you may need dynamic styles that depend on the element's state, attributes, or properties at the time it's connected to the DOM.
This is where the resolveStyles()
method becomes valuable. It allows you to dynamically generate and apply styles during the connectedCallback
phase of the custom element lifecycle.
The resolveStyles()
method is called when the element is attached to the DOM (in the connectedCallback
phase).
Use resolveStyles()
when:
- The styles need to change based on a property or attribute.
- The component's appearance must adapt dynamically when it's rendered.
@customElement({
name: "my-button",
template: html<MyButton>` <button>${(x) => x.label}</button> `,
styles: (x) => x.resolveStyles(), // Use resolveStyles to dynamically resolve styles
})
export class MyButton extends GenesisElement {
label: string = "Click Me";
resolveStyles() {
// Dynamically return styles based on the label value
return this.label === "Click Me"
? css`
button {
background-color: blue;
}
`
: css`
button {
background-color: red;
}
`;
}
}
resolveStyles()
is called when the element is connected to the DOM.- The method checks the
label
property. If thelabel
is "Click Me", the button will have a blue background. Otherwise, it will have a red background.
You can further extend this example to update styles dynamically based on attributes or properties, such as a type or theme:
@customElement({
name: "my-button",
template: html<MyButton>` <button>${(x) => x.label}</button> `,
styles: (x) => x.resolveStyles(), // Use resolveStyles to dynamically resolve styles
})
export class MyButton extends GenesisElement {
@attr type: string = "primary"; // Attribute controlling style
label: string = "Click Me";
resolveStyles() {
switch (this.type) {
case "primary":
return css`
button {
background-color: blue;
color: white;
}
`;
case "secondary":
return css`
button {
background-color: gray;
color: black;
}
`;
case "danger":
return css`
button {
background-color: red;
color: white;
}
`;
default:
return css`
button {
background-color: white;
color: black;
}
`;
}
}
}
The type
attribute determines the button's style:
<my-button type="primary"></my-button>
→ Blue button.<my-button type="secondary"></my-button>
→ Gray button.<my-button type="danger"></my-button>
→ Red button.resolveStyles()
is automatically called duringconnectedCallback
and applies the correct styles based on the value of the type attribute.- we can dynamically update the
type
attribute, and the styles will update accordingly.
Hiding Undefined Elements
When using custom elements (like <my-button>
), the browser may render them before they are fully defined or styled.
This can cause a "flash of unstyled content" (FOUC), where the element appears in its default, unstyled form for a brief moment.
To prevent the FOUC issue, we can hide custom elements that haven’t been upgraded yet by using the CSS :not(:defined)
pseudo-class.
:not(:defined)
matches any element that has not yet been defined (i.e., upgraded by the browser).- Using
visibility: hidden
, we hide these elements from view until their definition and styles are applied.
See implementation below:
:not(:defined) {
visibility: hidden; /* Hides all custom elements until defined */
}
Using visibility: hidden
ensures that the element is still part of the document flow but not visible to the user. This prevents layout shifts when the element is styled.