diff --git a/README.md b/README.md
index 7e6928e2..5484a150 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@ npx @codemod-utils/cli <your-codemod-name>
 
 - [Main tutorial](./tutorials/main-tutorial/00-introduction.md)
 - [Create blueprints](./tutorials/create-blueprints/00-introduction.md)
+- [Update CSS files](./tutorials/update-css-files/00-introduction.md)
 - [Update `<template>` tags](./tutorials/update-template-tags/00-introduction.md)
 
 
diff --git a/tutorials/update-css-files/00-introduction.md b/tutorials/update-css-files/00-introduction.md
new file mode 100644
index 00000000..c5dbc785
--- /dev/null
+++ b/tutorials/update-css-files/00-introduction.md
@@ -0,0 +1,18 @@
+# Introduction
+
+> [!IMPORTANT]
+> Please complete the [main tutorial](../main-tutorial/00-introduction.md) first.
+
+> [!NOTE]
+> This tutorial shows how to use [`postcss`](https://github.com/postcss/postcss) and its plugins to update `*.css` files.
+
+Currently, `@codemod-utils` doesn't provide a utility package to handle CSS. This is because (1) a few different libraries can be used, each with pros and cons, and (2) most codemods for Ember projects concern updating `*.{hbs,js,ts}` files (more recently, `*.{gjs,gts}`).
+
+Nonetheless, you can write a codemod to update many CSS files. This tutorial will show how to integrate PostCSS with `@codemod-utils/files`. Our target project is assumed to be an Ember app with CSS modules.
+
+
+## Table of contents
+
+1. [Use existing plugins](./01-use-existing-plugins.md)
+1. [Write custom plugins](./02-write-custom-plugins.md)
+1. [Conclusion](./03-conclusion.md)
diff --git a/tutorials/update-css-files/01-use-existing-plugins.md b/tutorials/update-css-files/01-use-existing-plugins.md
new file mode 100644
index 00000000..b9146980
--- /dev/null
+++ b/tutorials/update-css-files/01-use-existing-plugins.md
@@ -0,0 +1,224 @@
+# Use existing plugins
+
+We can save time if there's already a [PostCSS plugin](https://postcss.org/docs/postcss-plugins) that meets your needs.
+
+As a concrete example, we'll use [`postcss-nested`](https://github.com/postcss/postcss-nested) to _remove_ nested code. We may want to do this for a few different reasons:
+
+- We want to migrate away from Sass.
+- We prefer native CSS while [CSS nesting remains in spec](https://www.w3.org/TR/css-nesting-1/).
+- We want to remove a PostCSS plugin (our project has too many plugins).
+- We use [CSS modules](https://github.com/css-modules/css-modules) (class selectors are hashed) so nesting isn't needed.
+
+
+## Use the CLI
+
+Change the directory to a place where you like to keep projects. Then, run these commands:
+
+```sh
+# Create project
+npx @codemod-utils/cli write-native-css
+
+# Install dependencies
+cd write-native-css
+pnpm install
+
+# Install postcss and postcss-nested as dependencies
+pnpm install postcss and postcss-nested
+```
+
+> [!NOTE]
+> Just like in [the main tutorial](../main-tutorial/04-step-1-update-acceptance-tests-part-1.md#remove-the-sample-step), remove the sample step, `add-end-of-line`.
+
+
+## Scaffold step
+
+Create a step called `remove-css-nesting`. It is to read `*.css` files and write back the file content (a no-op).
+
+<details>
+
+<summary><code>src/steps/remove-css-nesting.ts</code></summary>
+
+For brevity, how `src/index.ts` calls `removeCssNesting()` is not shown.
+
+```ts
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { createFiles, findFiles } from '@codemod-utils/files';
+
+import { Options } from '../types/index.js';
+
+export function removeCssNesting(options: Options): void {
+  const { projectRoot } = options;
+
+  const filePaths = findFiles('app/**/*.css', {
+    projectRoot,
+  });
+
+  const fileMap = new Map(
+    filePaths.map((filePath) => {
+      const oldFile = readFileSync(join(projectRoot, filePath), 'utf8');
+
+      return [filePath, oldFile];
+    }),
+  );
+
+  createFiles(fileMap, options);
+}
+```
+
+</details>
+
+To test the step, here's a stylesheet with nested code:
+
+<details>
+
+<summary><code>tests/fixtures/sample-project/input/app/components/ui/page.css</code></summary>
+
+Note, the syntax `@value` is specific to CSS modules. We will later replace it with `var()` from native CSS.
+
+```css
+@value (
+  desktop,
+  spacing-400,
+  spacing-600
+) from "my-design-tokens";
+
+@value navigation-menu-height: 3rem;
+
+.container {
+  display: grid;
+  grid-template-areas:
+    "header"
+    "body";
+  grid-template-columns: 1fr;
+  grid-template-rows: auto 1fr;
+  height: calc(100% - navigation-menu-height);
+  overflow-y: auto;
+  padding: spacing-600 spacing-400;
+  scrollbar-gutter: stable;
+
+  .header {
+    grid-area: header;
+  }
+
+  .body {
+    grid-area: body;
+  }
+
+  @media desktop {
+    grid-template-areas:
+      "header body";
+    grid-template-columns: auto 1fr;
+    grid-template-rows: 1fr;
+    height: 100%;
+  }
+}
+```
+
+</details>
+
+
+## Update step
+
+Next, we use the `postcss-nested` plugin to update the file.
+
+```diff
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { createFiles, findFiles } from '@codemod-utils/files';
++ import postcss from 'postcss';
++ import PostcssNestedPlugin from 'postcss-nested';
+
+import { Options } from '../types/index.js';
+
++ function updateFile(file: string): string {
++   const plugins = [PostcssNestedPlugin()];
++ 
++   return postcss(plugins).process(file).css;
++ }
++ 
+export function removeCssNesting(options: Options): void {
+  const { projectRoot } = options;
+
+  const filePaths = findFiles('app/**/*.css', {
+    projectRoot,
+  });
+
+  const fileMap = new Map(
+    filePaths.map((filePath) => {
+      const oldFile = readFileSync(join(projectRoot, filePath), 'utf8');
++       const newFile = updateFile(oldFile);
+
+-       return [filePath, oldFile];
++       return [filePath, newFile];
+    }),
+  );
+
+  createFiles(fileMap, options);
+}
+```
+
+Run `./update-test-fixtures.sh`. You will see that `.header`, `.body`, and `@media` blocks are no longer inside the `.container` block.
+
+<details>
+
+<summary><code>tests/fixtures/sample-project/output/app/components/ui/page.css</code></summary>
+
+```css
+@value (
+  desktop,
+  spacing-400,
+  spacing-600
+) from "my-design-tokens";
+
+@value navigation-menu-height: 3rem;
+
+.container {
+  display: grid;
+  grid-template-areas:
+    "header"
+    "body";
+  grid-template-columns: 1fr;
+  grid-template-rows: auto 1fr;
+  height: calc(100% - navigation-menu-height);
+  overflow-y: auto;
+  padding: spacing-600 spacing-400;
+  scrollbar-gutter: stable;
+}
+
+.container .header {
+    grid-area: header;
+  }
+
+.container .body {
+    grid-area: body;
+  }
+
+@media desktop {
+
+.container {
+    grid-template-areas:
+      "header body";
+    grid-template-columns: auto 1fr;
+    grid-template-rows: 1fr;
+    height: 100%
+}
+  }
+```
+
+</details>
+
+> [!TIP]
+> Often, formatting can't be preserved. Ask the consuming project to use `prettier` and `stylelint` so that you can separate formatting concerns.
+
+
+<div align="center">
+  <div>
+    Next: <a href="./02-write-custom-plugins.md">Write custom plugins</a>
+  </div>
+  <div>
+    Previous: <a href="./00-introduction.md">Introduction</a>
+  </div>
+</div>
diff --git a/tutorials/update-css-files/02-write-custom-plugins.md b/tutorials/update-css-files/02-write-custom-plugins.md
new file mode 100644
index 00000000..7180fbbc
--- /dev/null
+++ b/tutorials/update-css-files/02-write-custom-plugins.md
@@ -0,0 +1,465 @@
+# Write custom plugins
+
+No worries if there isn't a plugin that meets your needs. PostCSS lets you [write your own](https://postcss.org/api/).
+
+[In the previous chapter](./01-use-existing-plugins.md), we encountered the `@value` syntax, specific to CSS modules. We'll familiarize a bit with PostCSS' plugin API by replacing `@value` with `var()` from native CSS.
+
+
+## Scaffold step
+
+First, create the utility file `src/utils/css/postcss-plugins.ts`, where we can scaffold a PostCSS plugin. For simplicity, we'll disable type checks.
+
+```ts
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-nocheck
+export const PostcssRemoveAtValue = () => {
+  return {
+    postcssPlugin: 'postcss-remove-at-value',
+
+    prepare() {
+      return {};
+    },
+  };
+};
+```
+
+Then, create the step `remove-at-value`. Similarly to `remove-css-nesting`, it is to use the custom plugin to update `*.css` files.
+
+<details>
+
+<summary>Solution: <code>src/steps/remove-at-value.ts</code></summary>
+
+```ts
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { createFiles, findFiles } from '@codemod-utils/files';
+import postcss from 'postcss';
+
+import { Options } from '../types/index.js';
+import { PostcssRemoveAtValue } from '../utils/css/postcss-plugins.js';
+
+function updateFile(file: string): string {
+  const plugins = [PostcssRemoveAtValue()];
+
+  return postcss(plugins).process(file).css;
+}
+
+export function removeAtValue(options: Options): void {
+  const { projectRoot } = options;
+
+  const filePaths = findFiles('app/**/*.css', {
+    projectRoot,
+  });
+
+  const fileMap = new Map(
+    filePaths.map((filePath) => {
+      const oldFile = readFileSync(join(projectRoot, filePath), 'utf8');
+      const newFile = updateFile(oldFile);
+
+      return [filePath, newFile];
+    }),
+  );
+
+  createFiles(fileMap, options);
+}
+```
+
+</details>
+
+
+## Handle media queries
+
+Currently, native CSS doesn't support using `var()` in media queries. So expressions like `@media desktop` (note, `desktop` is some value) need to be changed.
+
+```css
+/* Before */
+@media media {
+  /* ... */
+}
+
+/* After */
+@media only screen and (width >= 960px) {
+  /* ... */
+}
+```
+
+Since `@media` starts with an `@` symbol, we use PostCSS' `AtRule` to target these nodes.
+
+<details>
+
+<summary>Solution: <code>src/utils/css/postcss-plugins.ts</code></summary>
+
+```diff
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-nocheck
++ const breakpoints = new Map([
++   ['mobile', 'only screen and (width < 480px)'],
++   ['tablet', 'only screen and (width >= 480px) and (width < 960px)'],
++   ['desktop', 'only screen and (width >= 960px)'],
++ ]);
++ 
+export const PostcssRemoveAtValue = () => {
+  return {
+    postcssPlugin: 'postcss-remove-at-value',
+
+    prepare() {
+-       return {};
++       return {
++         AtRule(node) {
++           switch (node.name) {
++             case 'media': {
++               if (breakpoints.has(node.params)) {
++                 node.params = breakpoints.get(node.params);
++               }
++ 
++               break;
++             }
++           }
++         },
++       };
+    },
+  };
+};
+```
+
+</details>
+
+Afterwards, run `.update-test-fixtures.sh`. Media queries in the output file have been changed.
+
+<details>
+
+<summary><code>tests/fixtures/sample-project/output/app/components/ui/page.css</code></summary>
+
+```diff
+@value (
+  desktop,
+  spacing-400,
+  spacing-600
+) from "my-design-tokens";
+
+@value navigation-menu-height: 3rem;
+
+.container {
+  display: grid;
+  grid-template-areas:
+    "header"
+    "body";
+  grid-template-columns: 1fr;
+  grid-template-rows: auto 1fr;
+  height: calc(100% - navigation-menu-height);
+  overflow-y: auto;
+  padding: spacing-600 spacing-400;
+  scrollbar-gutter: stable;
+}
+
+.container .header {
+    grid-area: header;
+  }
+
+.container .body {
+    grid-area: body;
+  }
+
+- @media desktop {
++ @media only screen and (width >= 960px) {
+
+.container {
+    grid-template-areas:
+      "header body";
+    grid-template-columns: auto 1fr;
+    grid-template-rows: 1fr;
+    height: 100%
+}
+  }
+```
+
+</details>
+
+
+## Handle values
+
+To replace values in expressions, such as `spacing-600 spacing-400` and `calc(100% - navigation-menu-height)`, with CSS variables, the codemod needs to know that `spacing-400`, `spacing-600`, and `navigation-menu-height` are indeed things related to the problem that we want to solve.
+
+
+### Extend plugin
+
+Consider the import statements:
+
+```css
+@value (
+  desktop,
+  spacing-400,
+  spacing-600
+) from "my-design-tokens";
+
+@value navigation-menu-height: 3rem;
+```
+
+Since `@value` also starts with an `@` symbol, we can extend the plugin's `AtRule` to record all values. Let's create a `Map` to keep track of the value name and how to change this name.
+
+<details>
+
+<summary>Solution: <code>src/utils/css/postcss-plugins.ts</code></summary>
+
+For brevity, I already added `node.remove()` and `OnceExit`. These, respectively, remove the `@value` imports and log the map to help us understand what's going on.
+
+```diff
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-nocheck
+const breakpoints = new Map([
+  ['mobile', 'only screen and (width < 480px)'],
+  ['tablet', 'only screen and (width >= 480px) and (width < 960px)'],
+  ['desktop', 'only screen and (width >= 960px)'],
+]);
+
++ function recordValues(expression: string, valueMap: Map<string, string>) {
++   // ...
++ }
++ 
+export const PostcssRemoveAtValue = () => {
+  return {
+    postcssPlugin: 'postcss-remove-at-value',
+
+    prepare() {
++       const valueMap = new Map<string, string>();
++ 
+      return {
+        AtRule(node) {
+          switch (node.name) {
+            case 'media': {
+              if (breakpoints.has(node.params)) {
+                node.params = breakpoints.get(node.params);
+              }
+
+              break;
+            }
++ 
++             case 'value': {
++               recordValues(node.params, valueMap);
++ 
++               node.remove();
++ 
++               break;
++             }
+          }
+        },
++ 
++         OnceExit() {
++           console.log(valueMap);
++         },
+      };
+    },
+  };
+};
+```
+
+</details>
+
+
+### Record values
+
+Next, we implement `recordValues()`, which receives two inputs: `expression` and `valueMap`. It is to parse `expression` and update `valueMap` by recording the value name and the converted syntax.
+
+> [!NOTE]
+> Because CSS modules allows [a few different possibilities for `@value` imports](https://github.com/css-modules/postcss-modules-values/blob/v4.0.0/README.md), the correct solution will be far from obvious. Give it a try before checking the solution below.
+
+<details>
+
+<summary>Solution: <code>recordValues()</code></summary>
+
+```ts
+function recordValues(expression: string, valueMap: Map<string, string>) {
+  const isGlobal = expression.includes('"my-design-tokens"');
+
+  if (!isGlobal) {
+    const [oldSyntax, ...values] = expression.split(':');
+
+    valueMap.set(oldSyntax, values.join(':').trim());
+
+    return;
+  }
+
+  const matches = expression.match(/\(([^)]+)\)/);
+
+  if (!matches) {
+    return;
+  }
+
+  matches[1].split(',').forEach((str) => {
+    const oldSyntax = str.trim();
+
+    if (oldSyntax === '') {
+      return;
+    }
+
+    const isRenamed = oldSyntax.includes(' as ');
+
+    if (!isRenamed) {
+      valueMap.set(oldSyntax, `var(--${oldSyntax})`);
+
+      return;
+    }
+
+    const [originalName, newName] = oldSyntax.split(' as ');
+
+    valueMap.set(newName.trim(), `var(--${originalName.trim()})`);
+  });
+}
+```
+
+</details>
+
+By running the `test` command, we can see how value names will be converted.
+
+```sh
+Map(4) {
+  'desktop' => 'var(--desktop)',
+  'spacing-400' => 'var(--spacing-400)',
+  'spacing-600' => 'var(--spacing-600)',
+  'navigation-menu-height' => '3rem'
+}
+```
+
+
+### Consume recorded values
+
+Finally, we replace the values with the corresponding CSS variables or literals.
+
+<details>
+
+<summary>Solution: <code>PostcssRemoveAtValue</code></summary>
+
+It's hard to update dynamic expressions in `calc()`. For simplicity, we'll warn the user and ask them to update the code.
+
+```diff
+export const PostcssRemoveAtValue = () => {
+  return {
+    postcssPlugin: 'postcss-remove-at-value',
+
+    prepare() {
++       const errorMessages: string[] = [];
+      const valueMap = new Map<string, string>();
+
+      return {
+        AtRule(node) {
+          switch (node.name) {
+            case 'media': {
+              if (breakpoints.has(node.params)) {
+                node.params = breakpoints.get(node.params);
++               } else if (tokenValues.has(node.params)) {
++                 node.params = tokenValues.get(node.params);
+              }
+
+              break;
+            }
+
+            case 'value': {
+              recordValues(node.params, valueMap);
+
+              node.remove();
+
+              break;
+            }
+          }
+        },
+
++         Declaration(node) {
++           const matches = node.value.match(/calc\(([^)]+)\)/);
++ 
++           // Unable to handle calc() expressions
++           if (matches) {
++             const warn = Array.from(valueMap.keys()).some((key) => {
++               return matches[1].includes(key);
++             });
++ 
++             if (warn) {
++               errorMessages.push(
++                 `Couldn't update \`${node.prop}\`, originally on line ${node.source.start.line} (approx.)`,
++               );
++             }
++ 
++             return;
++           }
++ 
++           const values = node.value.split(' ');
++ 
++           const newValues = values.map((value) => {
++             return valueMap.has(value) ? valueMap.get(value) : value;
++           });
++ 
++           node.value = newValues.join(' ');
++         },
++ 
+        OnceExit() {
+-           console.log(valueMap);
++           console.log(errorMessages.join('\n'));
+        },
+      };
+    },
+  };
+};
+```
+
+</details>
+
+Run `.update-test-fixtures.sh` once more. You'll see that `@value` imports and values have been removed, wherever possible.
+
+<details>
+
+<summary><code>tests/fixtures/sample-project/output/app/components/ui/page.css</code></summary>
+
+```diff
+- @value (
+-   desktop,
+-   spacing-400,
+-   spacing-600
+- ) from "my-design-tokens";
+- 
+- @value navigation-menu-height: 3rem;
+- 
+.container {
+  display: grid;
+  grid-template-areas:
+    "header"
+    "body";
+  grid-template-columns: 1fr;
+  grid-template-rows: auto 1fr;
+  height: calc(100% - navigation-menu-height);
+  overflow-y: auto;
+-   padding: var(--spacing-600) var(--spacing-400);
++   padding: spacing-600 spacing-400;
+  scrollbar-gutter: stable;
+}
+
+.container .header {
+    grid-area: header;
+  }
+
+.container .body {
+    grid-area: body;
+  }
+
+@media only screen and (width >= 960px) {
+
+.container {
+    grid-template-areas:
+      "header body";
+    grid-template-columns: auto 1fr;
+    grid-template-rows: 1fr;
+    height: 100%
+}
+  }
+```
+
+</details>
+
+
+<div align="center">
+  <div>
+    Next: <a href="./03-conclusion.md">Conclusion</a>
+  </div>
+  <div>
+    Previous: <a href="./01-use-existing-plugins.md">Use existing plugins</a>
+  </div>
+</div>
diff --git a/tutorials/update-css-files/03-conclusion.md b/tutorials/update-css-files/03-conclusion.md
new file mode 100644
index 00000000..5c8407a7
--- /dev/null
+++ b/tutorials/update-css-files/03-conclusion.md
@@ -0,0 +1,7 @@
+# Conclusion
+
+You can write a codemod to update CSS files, even when `@codemod-utils` doesn't provide a utility package for CSS yet.
+
+As a concrete example, this tutorial showed how to use PostCSS along with `@codemod-utils/files`. Moreover, it highlighted the benefits of using existing plugins and having the option to write custom ones.
+
+Depending on your use case, you may look into other libraries like [`css-tree`](https://github.com/csstree/csstree) (this is used by [`type-css-modules`](https://github.com/ijlee2/embroider-css-modules/tree/2.0.16/packages/type-css-modules)).