MarsBased Tapas

Modern CSS

you're not using yet

Finally, a parent selector (and four friends)

Filters elements out. Modern :not() accepts selector lists and full patterns, not just simple selectors.

/* Border on all but last */
.list-item:not(:last-child) {
  border-bottom: 1px solid var(--border);
}

/* Stack spacing, skip last */
.stack > *:not(:last-child) {
  margin-bottom: 16px;
}
  • Dashboard
  • Analytics
  • Settings
  • Billing

Border on every item except the last — no wrapper trick needed

/* .intro keeps its default style */
.article p:not(.intro) {
  color: var(--ink-muted);
  font-size: 14px;
}

This is the intro paragraph — it keeps its default style.

Regular paragraphs get the muted color applied via :not(.intro).

Another regular paragraph, also matched and styled consistently.

.intro is excluded — all other p elements get the muted style

/* Only real links get underline */
a:not(.button) {
  text-decoration: underline;
  color: var(--code-sel);
}

Same element, same tag — .button opt-out removes the underline

/* Hover skips disabled state */
.button:not(:disabled):hover {
  background: var(--color-1);
  color: var(--ink);
}

Hover only activates on the enabled button — :disabled is excluded

/* List + complex selector */
.content a:not(.button, nav a) {
  text-decoration: underline;
  color: var(--code-sel);
}

Read the release notes before publishing.

Download kit

Only the content link matches; .button and nav a are excluded in one :not()

Matches any option in a selector list. Great for removing repeated prefixes. Specificity follows the most specific option in the list.

/* Before */
.form input,
.form select,
.form textarea {
  font-family: inherit;
  border-radius: 6px;
  padding: 8px 12px;
}

/* With :is() */
.form :is(input, select, textarea) {
  font-family: inherit;
  border-radius: 6px;
  padding: 8px 12px;
}

One rule styles input, select, and textarea without repeating .form

/* Before */
.btn:hover,
.btn:focus-visible {
  background: var(--color-1);
  color: var(--ink);
}

/* With :is() */
.btn:is(:hover, :focus-visible) {
  background: var(--color-1);
  color: var(--ink);
}

Hover or focus-visible share the same state styles

/* Before */
.card h1,
.card h2,
.card h3 {
  margin-bottom: 0.5em;
  color: var(--ink);
}

/* With :is() */
.card :is(h1, h2, h3) {
  margin-bottom: 0.5em;
  color: var(--ink);
}

Main heading

This card uses an h1 — semantically the top of the page.

Section heading

This one uses h2 — a subsection inside an article.

Card title

This uses h3 — same visual result, different semantic level.

Different heading levels, one visual treatment

Matches like :is(), but adds zero specificity. Use it for defaults that should stay easy to override.

/* Without :where() — same specificity */
.btn-primary { background: red; }  /* (0,1,0) */
.btn         { background: blue; } /* (0,1,0) — cascade wins */

/* With :where() — zero specificity */
.btn-primary  { background: red; }  /* (0,1,0) — wins */
:where(.btn)  { background: blue; } /* (0,0,0) */
Without :where() both end up blue
With :where() each gets its color

The base button rule stays weak, so .btn-primary wins cleanly

Matches an element when a selector matches relative to it. Parents can react to child state, content, and interaction, no JS required.

/* Invalid input highlights parent */
.form-group:has(input:invalid) {
  border-color: red;
}

/* Checked label turns bold */
label:has(input:checked) {
  font-weight: bold;
}

Parents react to invalid and checked children, no JS

/* Whole card lifts on link hover */
.card:has(a:hover) {
  box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}

/* Image card loses top padding */
.card:has(img) {
  padding-top: 0;
}
Cover
With image

Padding-top is removed.

Read more
Without image

Normal padding applies.

Read more

Hover the links — the whole card reacts via :has(a:hover)

/* Highlight nav item with current link */
.nav-item:has([aria-current="page"]) {
  border-bottom: 2px solid purple;
  color: var(--ink);
  font-weight: 600;
}

The nav item highlights when its link marks the current page

/* :has() + :is() — focus on any field */
.form-group:has(
  :is(input, textarea):focus
) {
  border-color: var(--color-1);
  box-shadow: 0 0 0 3px
    oklch(from var(--color-1)
    l c h / 0.25);
}

Click either field — the parent highlights on input or textarea focus

Components that finally mind their own business

Components respond to the space they actually get, not the viewport. Mark a wrapper with container-type, then restyle its children with @container.

.profile-wrapper {
  container-type: inline-size; /* or size, normal */
  container-name: profile;
}

@container profile (min-width: 350px) {
  .card__banner {
    flex-direction: row;
  }
  .card__actions {
    flex-direction: row;
  }
  .card__data {
    grid-template-columns: 1fr 1fr;
  }
}
Profiles: 1
Ahmed Wu
@Ahmed7
New York, US CTO Jan 18, 2023 205 Posts

Responsive UI without guessing the page layout. The page numbers appear only when the pagination container has room.

.pagination {
  container-type: inline-size; /* or size, normal */
  container-name: pager;
}

.pagination__pages {
  display: none;
}

@container pager (min-width: 500px) {
  .pagination__pages {
    display: flex;
  }
}

Container units are viewport units for components. cqw and cqh resolve against the query container's width and height.

.container {
  container-type: size;
}

.title {
  font-size: clamp(24px, 8cqw, 56px);
}

.bar--width {
  width: 40cqw;
}

.bar--height {
  width: 40cqh;
}

Resize me

40cqw
40cqh

For when flexbox and magic numbers aren't enough

A grid child can inherit its parent's rows or columns with subgrid. Nested content lines up without fake tables or fixed heights.

.pricing {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  grid-template-rows: auto repeat(5, auto);
}

.card {
  display: grid;
  grid-row: span 6;
  grid-template-rows: subgrid;
}

.card ul {
  display: contents;
}

Pro Package

  • Up to 4 team accounts.
  • Basic workflows.
  • Connect with Slack.
  • Up to 3 knowledge bases, with 100gb total storage.
  • Limited AI assistant (depending on region and language).

Enterprise Package

  • Unlimited team accounts.
  • Advanced, fully-customizable workflows.
  • Connect with Slack, Microsoft Teams, Discord, and 5 other popular integrations.
  • Unlimited knowledge bases.
  • Unlimited AI assistant.

Build table-like layouts without <table>. Without subgrid, each row sizes its columns independently. With it, every column lines up.

.table {
  display: grid;
  grid-template-columns:
    auto auto auto auto;
}

.row {
  display: grid;
  grid-column: 1 / -1;
  grid-template-columns: subgrid;
}
Name Role Location Status
Elena Voss Designer Berlin, DE Active
James Okoro Engineering Manager Lagos, NG Away
Mei Chen PM Taipei, TW Active
Luca Rossi DevOps Milan, IT Busy

Remember lighten($color, 20%)?

CSS can now mix, derive, and interpolate colors directly. Build palettes, gradients, and theme-aware tokens without a preprocessor.

/* Tint — mix with white */
--tint: color-mix(
  in oklch,
  var(--accent) 75%, white
);

/* Shade — mix with black */
--shade: color-mix(
  in oklch,
  var(--accent), black 25%
);

/* Blend two colors */
--blend: color-mix(
  in oklch,
  coral 50%, teal
);
100%
75%
50%
25%
Tint scale — mix with white
100%
75%
50%
25%
Shade scale — mix with black
+
=
Blend — coral + teal

Mix tokens at runtime, in the color space you choose

/* Lightness  Chroma  Hue */
color: oklch(65% 0.2 270);

/* Same C and h, step L by 15%:
   each swatch is the same
   perceptual distance apart */
.palette--1 { background: oklch(90% 0.15 290); }
.palette--2 { background: oklch(75% 0.15 290); }
.palette--3 { background: oklch(60% 0.15 290); }
.palette--4 { background: oklch(45% 0.15 290); }
.palette--5 { background: oklch(30% 0.15 290); }
.palette--6 { background: oklch(15% 0.15 290); }

Changing L creates even perceptual steps across the palette

/* Default: oklab,
   desaturated middle */
background: linear-gradient(
  to right, ...
);

/* sRGB: legacy, muddy middle */
background: linear-gradient(
  in srgb to right, ...
);

/* Explicit oklch:
   travels the hue circle */
background: linear-gradient(
  in oklch to right, ...
);
oklab (default) — desaturated middle
sRGB — muddy middle
OKLCH — vivid all the way

Same endpoints, different interpolation space

/* Derive variants from one token */
--accent: oklch(55% 0.2 290);

--accent-soft: oklch(from var(--accent)
  calc(l + 0.12) c h);

--accent-strong: oklch(from var(--accent)
  calc(l - 0.12) c h);

--accent-alpha: oklch(from var(--accent)
  l c h / 0.35);
--accent oklch(55% 0.2 290)
--accent-soft l + 0.12
--accent-strong l − 0.12
--accent-alpha alpha 0.35

Derived at runtime from one base token

/* Opt in to both schemes */
:root {
  color-scheme: light dark;
}

/* Resolves per color-scheme */
.heading {
  color: light-dark(
    oklch(20% 0.02 260),
    oklch(92% 0.02 260)
  );
}

.surface {
  background: light-dark(
    oklch(98% 0.01 260),
    oklch(20% 0.02 260)
  );
}
Same CSS, both color schemes
light

Typeface

A carefully balanced serif with crisp terminals and a generous x-height.

dark

Typeface

A carefully balanced serif with crisp terminals and a generous x-height.

No media query: values resolve from the active color scheme

Bye-bye, IntersectionObserver

Attach an animation to an element's position in the scrollport. With view(), progress comes from entering and crossing the nearest scrollport.

.item {
  animation: wipe-right linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}

@keyframes wipe-right {
  from {
    clip-path: inset(0 100% 0 0);
  }
  to {
    clip-path: inset(0);
  }
}

@keyframes circle-in {
  from {
    clip-path: circle(0% at 50% 50%);
  }
  to {
    clip-path: circle(100% at 50% 50%);
  }
}
scroll
wipe-right
wipe-up
circle-in
wipe-left
diamond-in
wipe-down
wipe-center
circle-in
stop

view() can take block/inline or x/y; use animation-range to tune when it runs

Bind an animation to a scroll container instead of time. The browser maps scroll progress to animation progress.

.container {
  overflow-y: auto;
}

.progress-bar {
  position: sticky;
  top: 0;
  transform-origin: left;
  animation: grow linear both;
  animation-timeline: scroll(nearest);
}

@keyframes grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

scroll(nearest) uses the closest scroller; use root, self, block/inline, or x/y when needed

Name a scroll timeline once, then attach animations to it from anywhere inside the section. Here, vertical scroll drives a sticky horizontal track.

.horizontal-wrapper {
  height: 400dvh;
  view-timeline-name: --section-scroll;
  view-timeline-axis: block;
}

.sticky {
  position: sticky;
  top: 0;
  height: 100dvh;
  overflow: hidden;
}

.track {
  display: flex;
  width: 400vw;
  animation: horizontal-pan linear both;
  animation-timeline: --section-scroll;
  animation-range: contain 0% contain 100%;
}

@keyframes horizontal-pan {
  to {
    transform: translateX(-75%);
  }
}
01
02
03
04

contain starts the pan once the sticky section fills the scrollport

The simplest pattern: view() plus opacity and transform keyframes. Each item fades in on entry and fades out on exit.

.item {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% exit 100%;
}

@keyframes reveal {
  entry 0% {
    opacity: 0;
  }
  entry 100% {
    opacity: 1;
  }
  exit 0% {
    opacity: 1;
    transform: translateY(0);
  }
  exit 100% {
    opacity: 0;
    transform: translateY(-40px);
  }
}
AL
Ana LopezProduct Designer
MR
Marc RuizFrontend Engineer
SP
Sara PonsBackend Engineer
JV
Joan VilaDevOps Lead
LM
Laia MasQA Engineer
PG
Pau GarciaData Analyst
NC
Nuria CostaProject Manager
DT
David TorresiOS Developer
EM
Elena MartinezUX Researcher
RF
Raul FerrerAndroid Developer
CB
Clara BravoTech Lead
OS
Oscar SanchezSRE Engineer

Each contact responds to its own position in the scrollport

Framer Motion? Now optional

Wrap the DOM update in startViewTransition(). That's the API: the browser captures before and after, then crossfades them by default.

button.onclick = () => {
  document.startViewTransition(() => {
    box.classList.toggle('red')
    box.classList.toggle('blue')
  })
}

No CSS yet. The default animation is a root crossfade.

Next: name one piece. The old element gets the name before the callback; the new element gets the same unique name inside it.

/* CSS: normal styles for each view */
.card-emoji { font-size: 28px; }
.detail-emoji { font-size: 64px; }

// JS: old snapshot, small emoji in the card
cardEmoji.style.viewTransitionName = 'selected-emoji'

const transition = document.startViewTransition(() => {
  list.hidden = true
  detail.hidden = false

  // New snapshot: large emoji in the detail view
  detailEmoji.textContent = cardEmoji.textContent
  detailEmoji.style.viewTransitionName = 'selected-emoji'
})

transition.finished.finally(() => {
  cardEmoji.style.viewTransitionName = ''
  detailEmoji.style.viewTransitionName = ''
})

Same name, two snapshots: small card emoji to large detail emoji.

Once a region is named, you can animate its old/new snapshots with CSS. ::view-transition-image-pair() clips the slide inside the form frame.

/* Name the wrapper so it animates on its own */
.content { view-transition-name: content; }

/* Clip to the box so slides don't spill out */
::view-transition-image-pair(content) {
  overflow: hidden;
}

html[data-dir='forward']::view-transition-old(content) {
  animation: slide-out-left 500ms ease-out both;
}
html[data-dir='forward']::view-transition-new(content) {
  animation: slide-in-right 500ms ease-out;
}
html[data-dir='back']::view-transition-old(content) {
  animation: slide-out-right 500ms ease-out both;
}
html[data-dir='back']::view-transition-new(content) {
  animation: slide-in-left 500ms ease-out;
}

// JS: direction chooses the old/new keyframes
function transitionToStep(next) {
  document.documentElement.dataset.dir =
    next > current ? 'forward' : 'back'

  document.startViewTransition(() => {
    showStep(next)
  })
}
Step 1 of 4

Contact information

Shipping address

Payment

Order confirmed

Your order number is #12345. A confirmation is on the way.

Back uses the same named region with the opposite keyframes.

Routes use the same pieces. SPAs wrap the render in startViewTransition(). Same-origin MPAs can opt in with @view-transition.

/* MPA: both pages opt in */
@view-transition {
  navigation: auto;
}

// SPA: wrap the route render
function navigate(route) {
  document.startViewTransition(() => {
    render(route)
  })
}

/* Shared pieces still need matching names */
.card.is-selected .thumb,
.detail .thumb {
  view-transition-name: route-hero;
}
tapas.dev/routes
Guides Quick evening picks
Barcelona this week

Three short routes to open after work

Works in any modern framework. Next.js App Router has native integration.

Thanks!

Questions?