css.joedoe.dev

Recipes

What I copy again and again from project to project. Each recipe has its demo running alongside: if it doesn't show in your browser, your browser doesn't support it yet.

Layout

Centre anything

Works everywhere

Two lines, centred on both axes. Works the same for an icon as for a modal.

.centro {
  display: grid;
  place-items: center;
}

A grid that fits itself

Works everywhere

As many columns as fit, no media queries. The min() keeps it from overflowing on narrow phones.

.rejilla {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
  gap: 1rem;
}

Footer stays at the bottom

Works everywhere

With little content the footer doesn't float halfway up. dvh accounts for the mobile browser bar.

body {
  min-height: 100dvh;
  display: grid;
  grid-template-rows: auto 1fr auto;
}

Fixed aspect ratio

Works everywhere

No more padding-top: 56.25% hack. The image is square and shows at 16:9: object-fit: cover crops it instead of stretching it (it works on img or video; on a div it does nothing).

.portada {
  aspect-ratio: 16 / 9;
  width: 100%;
  object-fit: cover;
}

Container queries

Works everywhere

The component changes with the room it has, not with the screen. Drag the box's corner to see it.

.tarjeta-wrap { container-type: inline-size; }

.tarjeta { display: grid; gap: 1rem; }

@container (min-width: 22rem) {
  .tarjeta { grid-template-columns: 6rem 1fr; }
}

Aligned cards with subgrid

Works everywhere

Titles of different lengths and still every button on the same line.

.tarjetas {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 1rem;
}

.tarjetas > * {
  display: grid;
  grid-row: span 3;
  grid-template-rows: subgrid;
}

A carousel with no JavaScript

Works everywhere

scroll-snap makes each card settle into place when you let go. With a finger or a trackpad.

.carrusel {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
}

.carrusel > * {
  flex: 0 0 80%;
  scroll-snap-align: center;
}

Anchors that don't hide under the header

Works everywhere

With a sticky header, the heading you jump to ends up covered. scroll-margin moves it down just enough.

html { scroll-behavior: smooth; }

:target,
h2[id] {
  scroll-margin-top: 5rem;
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

Text

Clamp text to N lines

Works everywhere

It carries a -webkit- prefix but works everywhere. For a single line, text-overflow: ellipsis.

.tres-lineas {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  overflow: hidden;
}

Balanced headings

Works everywhere, recently

No more last line with one lonely word. pretty does the same for paragraphs; where it isn't supported yet, it just does nothing.

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

p {
  text-wrap: pretty;
}

Gradient text

Works everywhere

With a bare color: transparent, a browser that can't clip the background would leave the text invisible. Inside @supports it only applies where it works; elsewhere the text is plain purple.

.degradado {
  color: #663399;
}

@supports (background-clip: text) or (-webkit-background-clip: text) {
  .degradado {
    background: linear-gradient(90deg, #663399, #e86f9b);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
  }
}

Selectors and cascade

Style the parent by its children: :has()

Works everywhere

The selector we asked for for twenty years. Here the field turns red when what you type isn't an email.

.campo:has(input:user-invalid) {
  border-color: crimson;
}

.campo:has(input:user-invalid) .error {
  display: block;
}

Order the cascade with @layer

Works everywhere

A later layer always wins, whatever the specificity. Goodbye to !important just to override a framework.

@layer reset, framework, mio;

@layer framework {
  #app .boton { background: gray; }
}

@layer mio {
  .boton { background: rebeccapurple; }
}

Nesting without Sass

Works everywhere

CSS nests on its own now. & works as in Sass, and media queries can go inside.

.boton {
  background: rebeccapurple;
  color: white;

  &:hover { background: color-mix(in oklch, rebeccapurple, black 20%); }

  .icono { margin-inline-end: .4em; }

  @media (width < 30rem) { width: 100%; }
}

Motion

Animate a popover in

Works everywhere, recently

@starting-style gives the starting point for something that just appeared, and allow-discrete lets display animate. No JS at all.

[popover] {
  transition: opacity .25s, translate .25s,
    display .25s allow-discrete, overlay .25s allow-discrete;
  opacity: 0;
  translate: 0 1rem;
}

[popover]:popover-open {
  opacity: 1;
  translate: 0 0;

  @starting-style {
    opacity: 0;
    translate: 0 1rem;
  }
}

Reveal on scroll

Some browser still missing

An animation that moves with scrolling instead of time. No IntersectionObserver. Scroll inside the demo.

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .aparece {
      animation: aparece linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 30%;
    }
  }
}

@keyframes aparece {
  from { opacity: 0; scale: .8; }
}

Respect “reduce motion”

Works everywhere

Animations make some people genuinely dizzy. This all but switches them off if they asked for it in their system settings.

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

Colour

Light and dark with light-dark()

Works everywhere, recently

Each colour states both versions on one line, and color-scheme brings form controls and scrollbars along.

:root {
  color-scheme: light dark;
  --fondo: light-dark(#fbfaf7, #17121f);
  --texto: light-dark(#221a2e, #ece6f5);
  --acento: light-dark(rebeccapurple, #c9a7f2);
}

body {
  background: var(--fondo);
  color: var(--texto);
}

States with color-mix()

Works everywhere

One brand colour, and hover, active and the soft background all come from it. Mixing in oklch gives cleaner tones.

.boton {
  --c: rebeccapurple;
  background: var(--c);
}
.boton:hover  { background: color-mix(in oklch, var(--c), white 15%); }
.boton:active { background: color-mix(in oklch, var(--c), black 20%); }
.suave        { background: color-mix(in oklch, var(--c) 12%, transparent); }

Form controls in your brand colour

Works everywhere

Native checkboxes, radios, sliders and progress bars in your colour, without rebuilding them.

:root {
  accent-color: rebeccapurple;
}

Accessibility

Focus ring only for the keyboard

Works everywhere

Never just remove the outline. With :focus-visible the ring shows when tabbing and not on click.

:focus-visible {
  outline: 3px solid rebeccapurple;
  outline-offset: 3px;
  border-radius: 4px;
}

Hidden from eyes, not from screen readers

Works everywhere

For the label of an icon-only button. display: none would hide it from people who can't see, too.

.solo-lectores {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

↑↓ move · ↵ open · esc close