CSS

Responsive layouts

rem vs em vs px

Responsive font sizing without media queries

font-size: clamp(2rem, 8vw, 4rem)

#todo viewport units

Selectors

Attribute selectors

Operator Meaning
~= a whitespace-separated list of words, one of which is exactly value
|= can be exactly value or can begin with value immediately followed by a hyphen
^= starts with value
$= ends with value
*= contains value
[... i] case insensitive

:focus-visible

button:focus-visible {
    outline: 2px solid white;
}

@supports not selector(:focus-visible) {
    button:focus {
        outline: 2px solid white;
    }
}

:user-valid and :user-invalid

:has

Warning

:has cannot be nested inside another :has!

div:has(+ span) {
    /* matches divs immediately followed by a span */
}

div:has(> span) {
    /* matches divs containing a span */
}

div:has(+ span, > span) {
    /* matches either of the above cases */
}

:is

:is(ol, ul) :is(ol, ul, :banana /* invalid selector is ignored */) {
    /* matches any nested list, ordered or unordered */
}

:where

:nth-child

Limiting with selectors

<article>
  <h2>Example</h2>
  <img class="hero" src="https://picsum.photos/seed/apple/200/100" />
  <img class="inline" src="https://picsum.photos/seed/orange/200/100" />
  <p>
    Culpa irure anim occaecat ut voluptate duis aliquip consequat esse id id ad
    aute labore. Dolore ipsum ipsum cillum veniam. Nisi ex eiusmod sunt et
    veniam.
  </p>
  <img class="inline" src="https://picsum.photos/seed/banana/200/100" />
  <p>
    Minim magna ullamco nostrud laboris quis reprehenderit minim ex et. Ipsum
    velit Lorem sint ex in aliqua tempor non sunt enim consequat incididunt
    adipisicing do.
  </p>
</article>
article > img.inline {
  float: left;
}

/* this won't work, because both img.inline have odd indexes! */
article > img.inline:nth-child(even) {
	float: right;
}

/* but this will */
article > img:nth-child(even of .inline) {
  float: right;
}
<ul>
  <li class="important">Item 1</li>
  <li>Item 2</li>
  <li class="anchor">Item 3</li>
  <li>Item 4</li>
  <li class="important">Item 5</li>
  <li>Item 6</li>
  <li>Item 7</li>
  <li class="important">Item 8</li>
  <li class="important">Item 9</li>
  <li>Item 10</li>
</ul>
/* ❌ this will select item 5 - it's after .anchor, and *separately* it's the second child with .important */
li.anchor ~ :nth-child(2 of .important) {
	color: lime;
}

/* ✅ This will select item 8, because it's the second child that is *after .anchor and has .important* */
:nth-child(2 of li.anchor ~ .important) {
	color: deepskyblue;
}

Properties

align- and justify-

Vertical centering without flex or grid!

Shorthand (place-)

Center elements within a container

.container {
    display: grid;
    place-content: center;
}

animation

Shorthand

Property Default
duration 0s
timing-function ease
delay 0s
iteration-count 1
direction normal
fill-mode none
play-state running
name none
animation: 3s ease-in 1s infinite reverse both running slidein;

aspect-ratio

aspect-ratio: 16 / 9;

backdrop-filter

Nested backdrop filters

Setting any of these values on an element turns it into a backdrop root:

Child elements can't "see through" a backdrop root, so backdrop-filter on children won't work as expected.

To fix this, move the backdrop-filter that's on the outer element to a pseudo-element:

.blurred-bg::before {
    content: '';
    position: absolute;
    width: 100%;
    height: 100%;
    backdrop-filter: blur(30px);
    z-index: -1;
}

background

Shorthand

Property Default
image none
position 0% 0%
size auto auto
origin padding-box
clip border-box
attachment scroll
repeat repeat
color transparent
background: repeat scroll 0% 0%/auto padding-box border-box none transparent;

box-decoration-break

Warning

In Safari, requires -webkit- prefix and only works on inline elements

box-decoration-break: clone;
Multi line text
without box-decoration-break
Multi line text
with box-decoration-break

box-shadow

Shorthand

inset? | offset-x | offset-y | blur-radius | spread-radius | color

box-shadow: 3px 3px red, inset -1em 0 0.4em blue;

Limit shadows to one side using clip-path

box-shadow: 0 0 16px 0 black;
clip-path: inset(0 0 -16px 0);
no clip-path
clip-path

break-before, break-inside, break-after

Info

There are many values, but these are the most common ones that are widely supported as of June 2024

clip-path

inset

clip-path: inset(5% 10% 15% 20%);

polygon

/* equilateral triangle */
clip-path: polygon(50% 0, 0% 100%, 100% 100%);

/* trapezoid that narrows at the top */
clip-path: polygon(20% 0, 80% 0, 100% 100%, 0% 100%);

circle

clip-path: circle(50% at 50% 50%);

ellipse

clip-path: ellipse(25% 40% at 50% 50%);

color-scheme

<meta name="color-scheme" content="dark-light">

contain

contain-intrinsic-size

/* will behave as 300x100 while in size containment */
contain-intrinsic-size: 300px 100px;

/* will behave as 300x100 in size containment until rendered, then remember its actual size */
contain-intrinsic-size: auto 300px 100px;

container, container-name, container-type

See [[#Marking containers]]

content-visibility

Improving rendering performance with CSS content-visibility

font-weight

Value Common weight name
100 Thin (Hairline)
200 Extra Light (Ultra Light)
300 Light
400 Normal (Regular)
500 Medium
600 Semi Bold (Demi Bold)
700 Bold
800 Extra Bold (Ultra Bold)
900 Black (Heavy)
950 Extra Black (Ultra Black)

forced-color-adjust

.button {
    forced-color-adjust: none;
}

gap

gap: 10px 5px;

hyphens

inset

inset: 1rem 2rem; /* top: 1rem; right: 2rem; bottom: 1rem; left: 2rem; */

inset-block, inset-inline

inset-block: 50px 100px; /* 50px start, 100px end */
inset-inline: 20px; /* 20px start and end */

interpolate-size and calc-size()

Danger

As of November 2024, supported in Chromium only

width: calc-size(min-content, size + 100px)

mask

mask-image: url('image.svg');
mask-mode: alpha; /* or luminance */
mask-composite: add; /* or subtract, intersect, exclude - if you have multiple masks, this controls how each one is composited with the masks below it */

/* these accept the same values as their background- equivalents */
mask-size: auto;
mask-position: center;
mask-clip: border-box;
mask-repeat: repeat;

Fade using a gradient

.fade p {
    mask-image: linear-gradient(to bottom, white, transparent);
}

Reprehenderit est tempor minim id cupidatat mollit velit sit. Eu magna ex nisi aute. Quis id culpa in ex incididunt est aliquip anim consectetur ipsum Lorem. Aute ullamco Lorem laboris tempor fugiat duis ex reprehenderit tempor et. Occaecat velit laborum sint aliquip eiusmod cillum sint esse officia. Nostrud minim duis anim minim tempor consectetur sit proident laboris ea et eiusmod. Irure aliquip ex amet cillum anim anim irure est ex cillum qui culpa aute.

mix-blend-mode

overscroll-behavior

outline-offset

outline:offset: 10px
outline:offset: -10px

resize

text-shadow

Shorthand

offset-x | offset-y | blur-radius | color

text-shadow: 1px 1px 2px black, 0 0 1em blue, 0 0 0.2em blue;

text-wrap

Warning

As of November 2024 pretty is only supported in Chromium, stable only in Firefox and Safari

wrap

Est dolor excepteur exercitation adipisicing. Aute occaecat cillum esse nulla do eiusmod. In et non mollit do incididunt nisi cupidatat duis. Excepteur eu excepteur dolore nisi occaecat eu enim deserunt.

pretty

Est dolor excepteur exercitation adipisicing. Aute occaecat cillum esse nulla do eiusmod. In et non mollit do incididunt nisi cupidatat duis. Excepteur eu excepteur dolore nisi occaecat eu enim deserunt.

balance

Est dolor excepteur exercitation adipisicing. Aute occaecat cillum esse nulla do eiusmod. In et non mollit do incididunt nisi cupidatat duis. Excepteur eu excepteur dolore nisi occaecat eu enim deserunt.

touch-action

transition

Transition from height 0 to auto

display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows;

transition-behavior

.card {
    transition-property: opacity, display;
    transition-duration: 0.25s;
    transition-behavior: allow-discrete;
}

.card.fade-out {
    opacity: 0;
    display: none;
}

user-select

Important

user-select: none should be used sparingly, and only for UI text that a user isn't likely to want to copy (like button labels).

user-select: none;

white-space

New lines Spaces and tabs Text wrapping End-of-line spaces End-of-line other space separators
normal Collapse Collapse Wrap Remove Hang
nowrap Collapse Collapse No wrap Remove Hang
pre Preserve Preserve No wrap Preserve No wrap
pre-wrap Preserve Preserve Wrap Hang Hang
pre-line Preserve Collapse Wrap Remove Hang
break-qspaces Preserve Preserve Wrap Wrap Wrap

zoom

zoom
scale

Flexbox

Make flex children the same size

Use flex: 1 1 100% to make every flex child the same size, regardless of content.

Prevent flex and grid items from overflowing container

By default, flex and grid items have min-width: auto and min-height: auto, meaning they can't be smaller than their content.

If the items are overflowing their container, set min-width: 0 or min-height: 0, or any overflow value other than visible on them.

Grid

Grid container properties

grid-template-columns, grid-template-rows

grid-template-columns: 50% 50%;
/* these are the same */
grid-template-rows: 20% 20% 20% 20% 20%;
grid-template-rows: repeat(5, 20%);

fr

grid-template-columns: 1fr 3fr;
This is a long sentence that will overflow this column if allowed
grid-template-columns: 25% 75%;
This is a long sentence that will overflow this column if allowed
1fr
3fr
25%
75% - overflowing!

minmax

auto-fill

grid-template-columns: repeat(auto-fill, 200px);

auto-fit

grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));

Named lines

main {
    grid-template-columns:
        [sidebar-start] 200px
        [sidebar-end article-start] 1fr [article-end];
}

.sidebar {
    grid-column: sidebar-start / sidebar-end;
}

.article {
    grid-column: article-start / article-end;
}
sidebar-start
sidebar-end
article-start
article-end

grid-template-areas

.grid {
    display: grid;
    grid-template-areas:
        "a a a"
        "b c c"
        "b c c";
}
a
b
c
grid-template-areas: 
    ". a a"
    "b c c"
    "b c c";
a
b
c

grid-template

grid-template: repeat(auto-fill, 200px) / repeat(2, 1fr);
grid-template:
    "a a a" 100px 
    "b c c" 50px
    "b c c" 50px / repeat(3, 1fr);

grid-auto-flow

grid-auto-rows, grid-auto-columns

grid-auto-rows: 100px;
grid-auto-columns: minmax(100px, auto);

Grid item properties

grid-row-start, grid-column-start, grid-row-end, grid-column-end

/* this element will be 2 cells wide */
grid-column-start: 2;
grid-column-end: span 2;

grid-row, grid-column

grid-area

position: absolute

display: contents

See also

Functions

min, max

width: min(100vw - 3rem, 80ch)

clamp

clamp(min, ideal, max)
font-size: clamp(1rem, 4vw, 3rem)

color-mix

color-mix(in oklab, red 25%, blue)

Color spaces.png

Relative color syntax

/* the input color can be in any format */*
color: rgb(from deepskyblue r g b);

/* can mix and match the individual components and numbers */
color: rgb(from deepskyblue g g 0);

/* or do math on them */
/* r g b channels are out of 255 */
color: rgb(from deepskyblue r calc(g * 2) b);

/* h is out of 255, s and l are out of 1 */
color: hsl(from deepskyblue h calc(s + .5) l);

Adjust transparency/alpha

/* reduce opacity by 50% */
color: rgb(from deepskyblue r g b / calc(alpha * 0.5))
/* reduce to 25% opacity */
color: hsl(from deepskyblue h s l / 0.25);

Lighten or darken a color

/* lightens the color by 25% */
color: oklab(from deepskyblue calc(l + 25) a b);

/* adjusts to 75% lightness regardless of the original value */
color: oklab(from deepskyblue 75% a b);

Saturate a color

color: hsl(from deeppink h calc(s + 0.5) l);
color: oklch(from deeppink l calc(c + 0.5) h);

Create a color palette from a single color

Varied lightness:

:root {
  --base-color: deeppink;

  --color-0: oklch(from var(--base-color) calc(l + 20) c h); /* lightest */
  --color-1: oklch(from var(--base-color) calc(l + 10) c h);
  --color-2: var(--base-color);
  --color-3: oklch(from var(--base-color) calc(l - 10) c h);
  --color-4: oklch(from var(--base-color) calc(l - 20) c h); /* darkest */
}

Hue rotation:

:root {
  --base-color: blue;

  --primary:   var(--base-color);
  --secondary: oklch(from var(--base-color) l c calc(h - 45));
  --tertiary:  oklch(from var(--base-color) l c calc(h + 45));
}

Combine the two:

:root {
  --base-color: deeppink;

  --color-1: var(--base-color);
  --color-2: oklch(from var(--base-color) calc(l - 10) c calc(h - 10));
  --color-3: oklch(from var(--base-color) calc(l - 20) c calc(h - 20));
  --color-4: oklch(from var(--base-color) calc(l - 30) c calc(h - 30));
  --color-5: oklch(from var(--base-color) calc(l - 40) c calc(h - 40));
}

light-dark

:root {
    color-scheme: light dark;
}

p {
    color: light-dark(black, white);
}

At-rules

@container (container queries)

.button {
    container-name: button;
    
    @container button style(--type: warning) {
        /* this doesn't work because we're trying to apply rules to the container that's being queried */
        background-color: red;

        & span {
            /* but this is fine */
            color: white;
        }
    }
    
    @container style(--type: warning) {
        /* this is valid if a parent of .button has --type: warning */
        background-color: red;
    }
}

Marking containers

Size queries

Create a grid that collapses when its container is below a certain size:

<section>
    <div class="grid"></div>
</section>
section {
    container-type: inline-size;
}

.grid {
	grid-template-columns: repeat(auto-fit, minmax(0%, 1fr));
}

@container (max-width: 500px) {
	.grid {
		grid-template-columns: 1fr;
	}
}
<article>
    <h2>Baked Ziti</h2>

    <section>
        <h3>Ingredients</h3>
        ...
    </section>

    <section>
        <h3>Directions</h3>
        ...
    </section>
</article>
article {
    /* can be targeted with the name `article` or `recipe` */
    container-name: article recipe;
    container-type: inline-size;
}

article section {
    /* shorthand */
    container: recipe-section / inline-size;
}

/* this will base header font size on the <article> width,
not the section width */
@container recipe (min-width: 500px) {
    h3 {
        font-size: 1.33em;
    }
}

/* same as above */
@container recipe (width >= 500px) {
    h3 {
        font-size: 1.33em;
    }
}

/* operator example */
@container recipe (width >= 500px) and (width < 1000px) {
    h3 {
        font-size: 1.33em;
    }
}

Style queries

Danger

As of November 2024, style queries for custom properties are not supported in Firefox, and no browsers support style queries for non-custom properties

@container style(--alignment) {
    /* rules will apply if the value of --alignment differs from the initial value */
}

@container card style(--alignment: start) {
    /* rules will apply if the value of --alignment on the `card` container is `start` */
}

@container card style(--alignment: start) or style(--alignment: end) {
    /* operators work here too */
}
/* not supported by any browsers as of November 2024! */
@container style(font-style: italic) {
    em, i {
        font-style: normal;
    }
}

@import (don't use it)

Avoid using @import, as it makes the browser download CSS sequentially and slows down rendering. Instead, link the stylesheets separately in your HTML, or use a bundler to combine them into one stylesheet.

@layer (Cascade Layers)

@layer reset, base, theme, utilities;

@import url(reset.css) layer(reset);

@layer base {
    button.filled {
        background-color: black;
        color: white;
    }
}

@layer theme {
    button {
        background-color: blue;
    }
    button.unthemed {
        background-color: revert-layer;
    }
}
<button class="filled">Click me!</button>
Important

Rules outside of layers take priority over layered rules!

<button class="filled unthemed">Click me!</button>
@layer framework {
    @layer base {
        ...
    }
    @layer theme {
        ...
    }
}

@layer framework.theme {
    ...
}

@media (media queries)

@media screen { }

@media print { }

prefers-color-scheme

@media (prefers-color-scheme: dark) { }

@media (prefers-color-scheme: light) { }

hover and any-hover

@media (hover: hover) {
    /* the primary input device can hover */
}

@media (any-hover: none) {
    /* none of the input devices can hover */
}

prefers-reduced-motion

@media (prefers-reduced-motion: reduce) {
    /* disable transitions or replace with a simple fade */
}

@media (prefers-reduced-motion: no-preference) { }

prefers-reduced-transparency

Warning

Supported in Chromium only as of November 2024

@media (prefers-reduced-transparency: reduce) {
    /* remove transparency and backdrop-filters */
}

@media (prefers-reduced-transparency: no-preference) { }

forced-colors

@media (forced-colors: active) { }

@media (forced-colors: none) { }

scripting

@media (scripting: none) {
  /* no JavaScript available */
}

@media (scripting: initial-only) {
  /* JavaScript only available during initial page load */
}

@media (scripting: enabled) {
  /* JavaScript fully available */
}

Boolean operators

and

@media (orientation: portrait) and (min-width: 300px) {
    /* portrait *and* at least 300px wide */
}

Multiple queries (or)

@media (orientation: landscape), (min-width: 300px) {
    /* landscape *or* at least 300px wide */
}

not

@media not (hover) { }
@media not screen and (color) { }
/* equivalent to */
@media not (screen and (color)) { }

@media not screen and (color), print and (color) { }
/* equivalent to */
@media (not (screen and (color))), print and (color) { }

Ranges

@media (width >= 300px) { }

@page

@page {
    margin: 1rem 0;
}

@page :first {
    margin-block-start: 0;
}

@property

@property --property-name {
  syntax: "<color>";
  inherits: false;
  initial-value: #c0ffee;
}
syntax: "<color>";
syntax: "<length> | <percentage>";
syntax: "small | medium | large"; /* custom ident example */
syntax: "*"; /* any value */

@scope

Danger

Not supported in Firefox as of June 2024

@scope (.card) {
    img {
        /* only images inside .card elements get this border */
        border: 2px solid black;
    }
}
@scope (.card) {
    :scope {
        /* selects the .card element itself */
    }

    & & {
        /* selects a .card inside the matched .card */
    }
}
@scope (.card) to (.card-content) {
    /* p elements inside .card-content won't be affected */
    p {
        font-weight: bold;
    }

    :scope {
        /* but they'll still inherit this text color if it's not overridden */
        color: gray;
    }
}

@scope (.card) to (:scope > .content) {
    /* elements inside .content won't be affected, but only if .content is a direct child of .card */
}

/* the limit can reference elements outside the scope */
@scope (.card) to (.sidebar :scope .content) {]
    /* .content only limits the scope if the card is inside .sidebar */
}
<div class="dark">
    <div class="light">
        <p>
    </div>
</div>
@scope (.light) {
    p {
        color: white;
    }
}

@scope (.dark) {
    p {
        color: white;
    }
}

@starting-style

Danger

As of November 2024, Firefox doesn't support animating from display: none

.alert {
  transition: background-color 2s;
  background-color: green;

  @starting-style {
    background-color: transparent;
  }
}

@supports

@supports (color: rgb(from white r g b)) {
    /* this browser supports relative color syntax */
}
@supports not (selector(:has(a, b))) {
    /* these rules will only apply to browsers that don't support :has() */
}

CSS Nesting

.foo {
    .bar {
        /* equivalent to .foo .bar */
    }

    & .bar {
        /* same as above */
    }

    .bar & {
        /* .bar .foo */
    }

    &.bar {
        /* .foo.bar (note the lack of space) */
    }

    + .bar {
        /* .foo + .bar */
    }

    div {
        /* this will be .foo div, but browser support is inconsistent - use "& div" instead */
    }

    /* ⛔️ you can't concatenate strings */
    &__bar {
        /* this won't equal .foo__bar! */
    }

    /* but you can do this, as long as the type selector comes first (but note the caveat about browser support) */
    div& {
        /* div.foo */
    }

    /* you can use & multiple times */
    &:nth-child(1 of &) {
        /* matches the first .foo within its parent */
    }
}

Nested @rules

.foo {
    color: black;

    @media (orientation: landscape) {
        /* by default matches the parent selector (.foo) */
        color: white;
        
        .bar {
            /* matches .foo .bar, but only in landscape */
        }

        @media (min-width > 1024px) {
            /* you can even nest multiple layers of @rules */
            /* equivalent to @media (orientation: landscape) and (min-width > 1024px) */
        }
    }
}
@layer base {
    @layer support {
        /* creates a layer called base.support */
    }
}

Animating dialogs and popovers

With [[#transition-behavior]]

dialog, [popover] {
    transition-property: opacity, display;
    transition-duration: 0.25s;

    /* when setting `display: none`, keeps the element visible until the end of the transition */
    transition-behavior: allow-discrete;

    @starting-style {
        /* triggers fade in when showing */
        opacity: 0;
    }
}

dialog:not([open]) {
        /* triggers fade out when hiding */
        opacity: 0;
    }
}

[popover]:not(:popover-open) {
        /* triggers fade out when hiding */
        opacity: 0;
    }
}

Without transition-behavior

function closeModal() {
    dialogEl.addEventListener('animationend', () => {
        dialog.close()
        dialog.classList.remove('fade-out')
    }, { once: true })
    
    dialog.classList.add('fade-out')
}

Other

Styling elements with multiple states

Custom property resolution

<div class="card">
    <p>Lorem ipsum dolor sit amet</p>
</div>
html {
    color: red;
}

/* When calculating the `color` value for <p>, this gets thrown away because there is a more specific selector (.card p) */
p {
    color: blue;
}

.card {
    --color: #notacolor;
}

/* This gets selected as the cascaded value for `color`, but the actual value is invalid! But since the value on `p` was already thrown away, the browser falls back to the red color from `html`. */
.card p {
    color: var(--color);
}
:root {
    --spacing: 0.5rem;
    --spacing-small: calc(var(--spacing) / 2); /* 0.25rem */
}

.element {
    --spacing: 1rem;
    /* --spacing-small is still 0.25rem because the value is
    calculated at the root level */
}
div {
  --color: red !important;
  --color: blue;
  /* red wins even though blue was declared later */
  color: var(--color);
}
div {
  --color: red !important;
  color: var(--color);
  /* yellow wins because !important is stripped out after the value of var(--color) is resolved */
  color: yellow;
}

unset vs. revert

For example, the default value of the display property is inline, but browsers set <div> elements to display: block.

Setting display: unset on a <div> will apply display: inline, which probably isn't what you want. Setting display: revert instead will apply display: block.

opacity: 0 vs. visibility: hidden

Intrinsic sizing keywords (min-content, fit-content, max-content)

min-content: Corporis aliquam rerum sint dolorem modi. Dolorum neque et eum dolorum reprehenderit est at ad.
fit-content: Corporis aliquam rerum sint dolorem modi.
fit-content: Corporis aliquam rerum sint dolorem modi. Dolorum neque et eum dolorum reprehenderit est at ad.
max-content: Corporis aliquam rerum sint dolorem modi. Dolorum neque et eum dolorum reprehenderit est at ad.

Font-based length units (ex, cap, ch, lh)

System color keywords

Keyword Example Description
AccentColor
Background of accented user interface controls
AccentColorText
Text of accented user interface controls
ActiveText
Text of active links
ButtonBorder
Base border color of controls
ButtonFace
Background color of controls
ButtonText
Text color of controls
Canvas
Background of application content or documents
CanvasText
Text color in application content or documents
Field
Background of input fields
FieldText
Text in input fields
GrayText
Text color for disabled items (e.g. a disabled control)
Highlight
Background of selected items
HighlightText
Text color of selected items
LinkText
Text of non-active, non-visited links
Mark
Background of text that has been specially marked (such as by the HTML mark element)
MarkText
Text that has been specially marked (such as by the HTML mark element)
VisitedText
Text of visited links

Negative transition/animation-delays

A negative transition-delay or animation-delay will start the transition/animation partway through. For example, transition-delay: -50ms will start the transition at the 50ms mark.

Make textareas resize to fit their content

Sync the textarea's contents to a data attribute on the wrapper:

<!-- vanilla example -->
<div class="grow-wrap" data-replicated-value="">
    <textarea onInput="this.parentNode.dataset.replicatedValue = this.value"></textarea>
</div>

<!-- Svelte example -->
<div class="grow-wrap" data-replicated-value={input}>
    <textarea bind:value={input}></textarea>
</div>

Style the wrapper the same as the textarea, overlay them using grid, and make the wrapper invisible

.grow-wrap {
    display: grid;
}

.grow-wrap textarea,
.grow-wrap::after {
    /* IMPORTANT: make sure the textarea and ::after element
       are styled exactly the same so they line up */
    white-space: pre-wrap;
    resize: none;
    overflow: hidden;
    /* position the textarea and ::after element in the same space */
    grid-area: 1 / 1 / 2 / 2;
}

.grow-wrap::after {
    content: attr(data-replicated-value) ' ';
    visibility: hidden;
    pointer-events: none;
}

Styling for print

See also