Just like JavaScript, CSS hasn’t the best reputation amongst (Rails) developers. And just like with JavaScript (think Turbo, but also CoffeeScript), CSS has a long history of pre-processors, post-processors and abstractions of it (think Tailwind CSS).
And for a long time many of these layered features where much needed. It is hard to imagine CSS without nesting selectors, is it?
But it is 2025 and CSS has (and is!) improving at a rapid speed. Gone are the days of spacer.gif or creating images for each corner of a card component to mimic border radii.
If you have been neglecting CSS for awhile (because you used Tailwind CSS, for example), below I want to highlight some of the newer CSS features that I am happily been using in some projects (that are build under the Rails Designer umbrella or others). While I still enjoy using Tailwind CSS; understanding and actually using real CSS is essential. The web is built on (open) standards which is important to keep it accessible, maintainable, and future-proof.
Min, max and clamp
Putting these together as they are overlapping.
min()
/* Keeps buttons from getting too wide on large screens */
.pricing-button {
width: min(300px, 90%);
}
Think of min()
as setting an upper limit. It will pick the smaller of the values. Handy for keeping elements from getting too big while still being responsive. So as long as 90%
calculates to be less than 300px
90% is used, once it gets beyond 300px
that value is used.
max()
/* Ensures text stays readable even on tiny screens */
.terms-container {
font-size: max(16px, 1.2vw);
}
max()
is like setting a minimum value, but it picks the larger option. Perfect for preventing elements from shrinking too much on mobile devices.
clamp()
/* Creates perfectly fluid typography that's never too big or small */
.dashboard-title {
font-size: clamp(1.5rem, 5vw, 3rem);
}
clamp()
is like having min()
and max()
combined! It takes three values:
- Minimum value
- Preferred value
- Maximum value
Container queries
Container queries make elements respond to their parent container’s size instead of the viewport width—which was previously the only way to make element appear differently.
/* Step 1: Mark the parent as a containment context */
.cards {
container-type: inline-size;
}
/* Step 2: Base styles for the card component */
.card {
padding: .75 1.25rem;
background: white;
border-radius: 1rem;
/* Default to stacked layout */
display: flex;
flex-direction: column;
gap: .5rem;
}
.card__value {
font-size: 2rem;
}
/* Step 3: Layout changes based on container width */
@container (width > 200px) {
.card {
/* Switch to horizontal layout when there's enough space */
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
text-wrap: balance & pretty
Controls how text is distributed across lines to improve readability. balance
creates even line lengths while pretty
prevents single words on last lines (called “orphans” with typography nerds).
/* text-wrap: balance - ideal for headings */
.section-title {
text-wrap: balance;
/* Makes lines more visually equal:
"Welcome to our Platform" becomes:
"Welcome to our
Platform"
Instead of:
"Welcome to
our Platform" */
}
/* text-wrap: pretty - ideal for paragraphs */
.card-description {
text-wrap: pretty;
/* Prevents orphaned words:
Adds extra line break to avoid:
"This is a long description about
features"
Creates instead:
"This is a long description
about features" */
}
@starting-style
Properly animate elements from display: none
using the new @starting-style
rule, which defines initial styles for when an element first becomes displayed.
.modal {
/* Hidden state */
display: none;
opacity: 1;
}
.modal.open {
display: block;
/* Regular transition works now */
transition: opacity 300ms;
opacity: 1;
}
/* Define starting styles when modal becomes displayed */
@starting-style {
.modal.open {
opacity: 0;
}
}
:has()
Allows selecting parent elements based on their children, previously impossible in CSS.
/* Card changes style if it contains an error message */
.card:has(.error-message) {
border-color: red;
background: rgb(255 0 0 / .05);
}
/* Form groups that have required inputs */
.form-group:has(input[required]) {
/* Show required indicator */
&::before {
content: "*";
color: red;
}
}
Media Query Ranges
Simplified syntax for setting min/max ranges in media queries, replacing verbose min-width
/max-width
with cleaner comparison operators.
/* Old syntax */
@media (min-width: 768px) and (max-width: 1199px) {
.card { width: 65ch; }
}
/* New range syntax */
@media (768px <= width <= 1199px) {
.card { width: 65ch; }
}
/* Common breakpoint patterns */
.container {
/* Smaller than 768px */
@media (width < 768px) {
padding: 1rem;
}
/* Larger than 1200px */
@media (width >= 1200px) {
max-width: 1140px;
}
/* Between breakpoints */
@media (480px <= width < 768px) {
margin: 1.5rem;
}
}
light-dark
Shorthand for setting different values based on user’s color scheme preference, without writing separate media queries.
.card {
/* Basic color values */
background: light-dark(#f1f5f9, #0f172a);
color: light-dark(#0f172a, #f1f5f9);
/* Works with any color format */
border: 1px solid light-dark(rgb(226, 232, 240), rgb(51, 65, 85));
}
color-scheme
Useful together with light-dark
, color-scheme
tells the browser which color schemes a component supports, allowing native elements (like scrollbars, form inputs) to automatically match the user’s preference.
/* Global level - typically in :root */
:root {
/* Support both schemes, dark listed first means dark is preferred */
color-scheme: dark light;
}
/* Component level - useful for cards or modals that differ from global scheme */
.themed-card {
/* This card only supports light mode */
color-scheme: light;
/* System UI elements inside will stay light */
input {
/* Inputs remain light-styled even in dark mode */
border: 1px solid #ddd;
}
}
/* Dark-only interface section */
.code-editor {
/* Force dark appearance for all system UI within */
color-scheme: dark;
}
Nesting
Straight from pre-processors (like Less and Sass). You can now natively write CSS with child selectors nested inside their parents. Helps maintain clear relationships between components and their children. Previously these relationships was made implicit using methodology like with BEM, eg. card__header
.
.card {
padding: .75 1.25rem;
background-color: #fff;
border-radius: 1rem;
/* Nest direct children with & */
& .header {
font-size: 1.7rem;
}
/* Nest state changes */
&:hover {
background: #f1f5f9;
}
/* Nest multiple selectors */
& .title,
& .subtitle {
font-weight: 600;
}
/* Nest media queries */
@media (width > 768px) {
padding: 1 1.375rem;
}
}
Units
In CSS, there are two main types of units: absolute and relative. Absolute units like cm
, mm
, in
(and others) represent fixed physical measurements and have been available since the early days of CSS. Pixels (px
) are actually relative units (they scale based on the viewing device). Most other units are also relative, scaling based on fonts, viewports, or other factors. CSS1 gave us em
for font-based sizing, CSS3 added viewport units like vh
and vw
(introduced around 2012), and recently (2022) we got dvh
to handle mobile browsers better. Note that several relative units (like em
, ex
, ch
) can compute differently based on where they’re used. Their behavior might change when used with font-size
versus other properties like width
or margin
.
Font-based
- em - Relative to the parent element’s font size
- rem - Relative to the root element’s font size
- ex - Represents the x-height of the current font (roughly the height of lowercase “x”) - this unit has been around since CSS1 but I only recently really learned about it
- ch - Width of the “0” (zero) character in the current font - available since CSS3 but often overlooked
- lh - Equal to the line-height of the element
Viewport-based
- vh - 1% of the viewport height
- vw - 1% of the viewport width
- vmin - 1% of the viewport’s smaller dimension (height or width) - also from CSS3 but less commonly used
- vmax - 1% of the viewport’s larger dimension (height or width) - also from CSS3 but less commonly used
Modern viewport
- dvh - Dynamic viewport height, adjusts for mobile browser chrome/UI elements
- dvw - Dynamic viewport width
- svh - Small viewport height, smallest possible height when accounting for dynamic UI elements
- svw - Small viewport width
- lvh - Large viewport height, largest possible height when accounting for dynamic UI elements
- lvw - Large viewport width
@layer
If you have used Tailwind CSS, this might look familiar. @layer
manages specificity conflicts by explicitly ordering style groups. Later layers always “win” regardless of specificity, solving the cascade that confuses (and annoys!) so many developers.
/* Define layer order - order here determines priority */
@layer reset, components, utilities;
/* Reset layer: lowest priority */
@layer reset {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
}
/* Components layer: middle priority */
@layer components {
.button {
/* Even if utilities have lower specificity,
they'll still override these styles */
padding: .5rem 1rem;
background: blue;
}
}
/* Utilities layer: highest priority */
@layer utilities {
.p-4 {
/* This wins over component padding */
padding: 1.25rem;
}
.bg-red {
/* This wins over component background */
background: red;
}
}
And that is just a small overview of some of the new properties I have been using in one of my latest products. Did you learn something new? Let me know!