• Tutorials
  • Modern CSS Features - Part 2

    Ansichten81

    Part 1 covered container queries, :has(), nesting, cascade layers, color-mix(), logical properties, and @property. This installment picks up where we left off, covering the features that make JavaScript-free interactivity and animation more achievable than ever.


    #@starting-style — Entry Animations Without JavaScript

    Before @starting-style, animating an element into the DOM required JavaScript to toggle a class after insertion. Now you can define the style an element starts from, purely in CSS.

     1dialog {
     2  opacity: 1;
     3  transform: translateY(0);
     4  transition: opacity 0.3s, transform 0.3s, display 0.3s allow-discrete;
     5
     6  @starting-style {
     7    opacity: 0;
     8    transform: translateY(-16px);
     9  }
    10}
    11
    12dialog:not([open]) {
    13  opacity: 0;
    14  transform: translateY(-16px);
    15  display: none;
    16}
    

    @starting-style defines the "before" snapshot — the style the browser reads on the element's very first render frame. The transition then plays forward from that snapshot to the element's normal styles.

    The allow-discrete keyword on the display transition is required when transitioning properties that are normally non-animatable, like display: none.

    Browser support: Chrome 117+, Firefox 129+, Safari 17.5+.


    #Anchor Positioning

    Anchor positioning lets any element act as a reference point for positioning another element — without JavaScript measuring getBoundingClientRect().

     1.tooltip-trigger {
     2  anchor-name: --trigger;
     3}
     4
     5.tooltip {
     6  position: absolute;
     7  position-anchor: --trigger;
     8
     9  /* Position the tooltip above the trigger */
    10  bottom: anchor(top);
    11  left: anchor(center);
    12  translate: -50% 0;
    13}
    

    The real power is position-try-fallbacks, which automatically repositions the element when it would overflow the viewport:

     1.tooltip {
     2  position: absolute;
     3  position-anchor: --trigger;
     4  bottom: anchor(top);
     5  left: anchor(center);
     6  translate: -50% 0;
     7
     8  position-try-fallbacks: --below, --left, --right;
     9}
    10
    11@position-try --below {
    12  bottom: auto;
    13  top: anchor(bottom);
    14}
    15
    16@position-try --left {
    17  left: auto;
    18  right: anchor(left);
    19  translate: 0 -50%;
    20}
    

    This replaces entire libraries dedicated to tooltip and popover positioning logic.

    Browser support: Chrome 125+, Edge 125+, Firefox 147+, Safari 26+.


    #light-dark() — Inline Theming

    light-dark() is a function that returns one of two values depending on the user's color scheme preference, without writing a @media (prefers-color-scheme) block.

     1:root {
     2  color-scheme: light dark;
     3}
     4
     5body {
     6  background: light-dark(#ffffff, #1a1a1a);
     7  color: light-dark(#111111, #eeeeee);
     8}
     9
    10.card {
    11  background: light-dark(#f5f5f5, #2a2a2a);
    12  border: 1px solid light-dark(#e0e0e0, #3a3a3a);
    13}
    

    The :root { color-scheme: light dark; } declaration is required — it tells the browser the page supports both modes and enables light-dark() to work.

    You can also control color scheme per subtree:

     1.inverted {
     2  color-scheme: dark;
     3  background: light-dark(#1a1a1a, #ffffff);
     4}
    

    Browser support: All major browsers since 2024.


    #Scroll-Driven Animations

    Scroll-driven animations let you tie CSS animation progress to scroll position — no scroll event listeners, no IntersectionObserver, no requestAnimationFrame.

    There are two types of scroll timelines:

    #scroll() — tied to a scrollable container

     1@keyframes progress {
     2  from { width: 0%; }
     3  to { width: 100%; }
     4}
     5
     6.reading-progress {
     7  animation: progress linear;
     8  animation-timeline: scroll(root block);
     9}
    

    The root keyword targets the page scroller; block means the vertical axis.

    Firefox is lacking support for this as of this moment.

    #view() — tied to an element's position in the viewport

     1@keyframes fade-in {
     2  from {
     3    opacity: 0;
     4    translate: 0 40px;
     5  }
     6  to {
     7    opacity: 1;
     8    translate: 0 0;
     9  }
    10}
    11
    12.reveal {
    13  animation: fade-in linear both;
    14  animation-timeline: view();
    15  animation-range: entry 0% entry 40%;
    16}
    

    animation-range controls which part of the element's journey through the viewport triggers the animation. entry 0% entry 40% means: play the full animation while the element travels from just entering the viewport to being 40% visible.

    This entirely replaces the common pattern of IntersectionObserver + toggling a .visible class.

    Browser support: Chrome 115+, Edge 115+, Firefox 114+ (behind a flag), Safari 26+.


    #text-wrap: balance and text-wrap: pretty

    Two new values for text-wrap that improve typographic quality automatically.

    #balance

    Distributes text evenly across lines, eliminating awkward single-word orphans in headings.

     1h1, h2, h3 {
     2  text-wrap: balance;
     3}
    

    Before: a heading might have four words on the first line and one on the second.
    After: the browser redistributes to two and three, or three and two.

    Best used on short text (headings, captions) — browsers cap it at around six lines for performance.

    #pretty

    Similar goal but for body text. It avoids leaving a single word alone on the last line of a paragraph, without rebalancing the entire block.

     1p {
     2  text-wrap: pretty;
     3}
    

    Browser support: balance — all major browsers since 2023. pretty — Chrome 117+, Edge 117+, Safari 26+.


    #field-sizing: content

    Inputs and textareas that automatically resize to fit their content, with no JavaScript.

     1textarea {
     2  field-sizing: content;
     3  min-height: 3lh;
     4  max-height: 20lh;
     5}
     6
     7input[type="text"] {
     8  field-sizing: content;
     9  min-width: 10ch;
    10}
    

    lh is the line-height unit — 3lh means "three line heights tall." Combined with min-height and max-height, this gives you a textarea that grows as the user types and stops at a sensible limit.

    Browser support: Chrome 123+, Edge 123+, Safari 26.2+. Firefox support is pending.


    #@layer and Third-Party Styles

    A practical pattern from Part 1 worth expanding: wrapping third-party CSS in a layer so your own styles always win without !important.

     1@import url("https://cdn.example.com/some-library.css") layer(vendor);
     2
     3@layer vendor, base, components, utilities;
     4
     5/* Your styles in a later layer always override vendor styles,
     6   regardless of specificity */
     7@layer components {
     8  .button {
     9    background: var(--brand);
    10  }
    11}
    

    Any @import can accept a layer() keyword directly. This is the cleanest way to integrate design systems, resets, or UI libraries into a layered architecture.


    #What's Coming in Part 3

    • View Transitions API (CSS side) — coordinated page and component transitions
    • if() in CSS — inline conditional values without custom properties
    • Relative color syntax — derive colors from other colors with oklch(from var(--base) l c h)
    • @scope — scoped styles without naming conventions or Shadow DOM

    All browser support notes refer to evergreen browsers as of early 2025. For production use, always verify on caniuse.com.

    profile image of Petar Vasilev

    Petar Vasilev

    Petar is a web developer at Mitkov Systems GmbH. He is fascinated with the web. Works with Laravel and its ecosystem. Loves learning new stuff.

    More posts from Petar Vasilev