Example postcss.config.js, and how to run PostCSS in one HTML file

PostCSS is a tool that transforms CSS with JavaScript plugins. A project reads its plugin list from postcss.config.js; a single HTML file has no config file, so the same list goes in a script.

A postcss.config.js file is the list of PostCSS plugins a project applies to its CSS. PostCSS is not a framework. It is a tool for transforming CSS with JavaScript, and it normally runs on Node during a build.

A single HTML file has no build step, so there the same plugin list sits in a script.

Try it first. Type CSS on the left and watch a one-plugin list rewrite it.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PostCSS in one HTML file</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; color: #1d2330; background: #fff; }
  h1 { font-size: 16px; margin: 0 0 4px; }
  p { margin: 0 0 10px; font-size: 13px; color: #4b5563; }
  .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  @media (max-width: 520px) { .cols { grid-template-columns: 1fr; } }
  label { display: block; font: 700 12px/1 ui-monospace, Consolas, monospace; margin-bottom: 5px; color: #374151; }
  textarea, pre {
    width: 100%; height: 190px; margin: 0; padding: 10px; border: 1px solid #d5d9e0; border-radius: 8px;
    font: 13px/1.5 ui-monospace, Consolas, monospace; background: #fafbfc; color: #1d2330; overflow: auto;
  }
  textarea { resize: vertical; }
  pre { background: #f0fdf4; border-color: #bbf7d0; white-space: pre-wrap; }
  pre.err { background: #fff7ed; border-color: #fdba74; color: #9a3412; }
</style>
</head>
<body>
<h1>One plugin, one function call</h1>
<p>Type CSS on the left. The plugin turns every <b>px</b> value into <b>rem</b> (16px = 1rem).</p>
<div class="cols">
  <div><label for="src">input.css</label>
<textarea id="src" spellcheck="false">.card {
  padding: 16px 24px;
  border-radius: 8px;
  font-size: 14px;
}</textarea></div>
  <div><label for="out">result.css</label><pre id="out">Loading PostCSS…</pre></div>
</div>

<script>
  const src = document.getElementById('src');
  const out = document.getElementById('out');

  // A PostCSS plugin is a function that returns an object with listeners.
  const pxToRem = () => ({
    postcssPlugin: 'px-to-rem',
    Declaration(decl) {
      decl.value = decl.value.replace(/(\d*\.?\d+)px/g, (m, n) => (n / 16) + 'rem');
    },
  });
  pxToRem.postcss = true;  // marks this function as a PostCSS 8 plugin

  let postcss;
  async function run() {
    if (!postcss) return;
    try {
      // the plugins array is the same list you would put in postcss.config.js
      const result = await postcss([pxToRem]).process(src.value, { from: undefined });
      out.className = '';
      out.textContent = result.css;
    } catch (e) {
      out.className = 'err';
      out.textContent = e.message;
    }
  }

  src.addEventListener('input', run);
  import('https://cdn.jsdelivr.net/npm/postcss@8.5.28/+esm')
    .then((m) => { postcss = m.default; run(); })
    .catch(() => { out.className = 'err'; out.textContent = 'Could not load PostCSS from the CDN.'; });
</script>
</body>
</html>
One plugin turns px into rem. Edit the CSS on the left; a typo shows PostCSS's own error message.

An example postcss.config.js

This is the usual shape. Plugins are installed with npm, then listed in the file.

// postcss.config.js
module.exports = {
  plugins: [
    require('autoprefixer'),
    require('postcss-nested'),
  ],
};
A postcss.config.js file: one plugins list, one entry per plugin, and the file names a tool will look for.
A postcss.config.js file: one plugins list, one entry per plugin, and the file names a tool will look for.

The loader that reads this file, postcss-load-config, also accepts an object form, where each key is a plugin name and each value is that plugin's options:

module.exports = {
  plugins: {
    autoprefixer: {},
    'postcss-nested': {},
  },
};

Besides postcss.config.js, the loader looks for the .cjs, .mjs and TypeScript variants, files named .postcssrc, and a postcss section in package.json. Vite, for one, applies a valid PostCSS config to all the CSS it imports.

What PostCSS does, and what it leaves to plugins

On its own, PostCSS parses CSS into a tree and prints it back out. The useful work is done by plugins that change the tree in between.

Autoprefixer is one such plugin. It adds vendor prefixes to CSS rules using values from Can I Use, and it takes the browsers you target from Browserslist. A query such as > 5% is one example from its README.

PostCSS is released under the MIT licence. Its project site describes it as a tool for transforming CSS with JavaScript.

Running PostCSS in one HTML file

In a project, Node runs PostCSS before the page exists. In a pasted page, the browser runs it after the page opens. The plugin list is the same idea either way.

Left: a build step on Node writes a CSS file. Right: a page imports PostCSS from a CDN and writes a style tag.
Left: a build step on Node writes a CSS file. Right: a page imports PostCSS from a CDN and writes a style tag.

The steps for a page:

  1. Import PostCSS with import() from jsDelivr, using the /+esm path and a pinned version.
  2. Define your plugins, or paste in small ones like those below.
  3. Call process on the processor, as in the snippet below, and await it.
  4. Use result.css: show it, or put it in a <style> tag.
<script>
  import('https://cdn.jsdelivr.net/npm/postcss@8.5.28/+esm').then(async (m) => {
    const postcss = m.default;
    const result = await postcss([myPlugin]).process(css, { from: undefined });
    document.getElementById('live').textContent = result.css;
  });
</script>

Dynamic import() returns a promise and works in a normal script tag. A static import line needs type="module". jsDelivr builds the /+esm file by turning the npm package into a browser-ready ES module.

The call to process returns a promise-like result. Without await or .then, result.css is not ready.

Writing a plugin

A PostCSS 8 plugin is a function that returns an object. The object has a postcssPlugin name and listeners. Declaration runs for every declaration, and Once runs once per file. Set postcss = true on the function so PostCSS recognises it:

const pxToRem = () => ({
  postcssPlugin: 'px-to-rem',
  Declaration(decl) {
    decl.value = decl.value.replace(/(\d*\.?\d+)px/g, (m, n) => (n / 16) + 'rem');
  },
});
pxToRem.postcss = true;

PostCSS revisits nodes that a plugin changed or added. Only Once and OnceExit are not called again. The plugin above is safe to revisit because its output contains no px for it to match.

Plugin order is part of the config

The array order is not decoration. Two plugins can each be correct and still give different CSS when swapped.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PostCSS plugin order</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; color: #1d2330; background: #fff; }
  h1 { font-size: 16px; margin: 0 0 4px; }
  p { margin: 0 0 10px; font-size: 13px; color: #4b5563; line-height: 1.45; }
  .list { display: grid; gap: 6px; margin-bottom: 12px; }
  .item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid #d5d9e0; border-radius: 8px; background: #fafbfc; }
  .item b { font: 700 13px ui-monospace, Consolas, monospace; flex: 1; }
  .item span { font-size: 12px; color: #6b7280; }
  button { font: inherit; font-size: 13px; padding: 6px 12px; border: 1px solid #c3c9d4; border-radius: 6px; background: #fff; cursor: pointer; }
  button:hover { background: #f1f3f7; }
  label { display: block; font: 700 12px/1 ui-monospace, Consolas, monospace; margin: 8px 0 5px; color: #374151; }
  pre { margin: 0; padding: 10px; border: 1px solid #bbf7d0; border-radius: 8px; background: #f0fdf4; font: 13px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; }
  pre.src { background: #fafbfc; border-color: #d5d9e0; }
  pre.err { background: #fff7ed; border-color: #fdba74; color: #9a3412; }
</style>
</head>
<body>
<h1>Plugins run in array order</h1>
<p><b>min-font</b> raises any font-size under 12px to 12px. <b>px-to-rem</b> turns px into rem. Press a button to swap them and watch the result change.</p>

<div class="list" id="list"></div>
<button id="swap" type="button">Swap order</button>

<label>input.css</label>
<pre class="src">.note { font-size: 11px; padding: 8px; }</pre>
<label>result.css</label>
<pre id="out">Loading PostCSS…</pre>

<script>
  const input = '.note { font-size: 11px; padding: 8px; }';

  const minFont = () => ({
    postcssPlugin: 'min-font',
    Declaration(decl) {
      if (decl.prop !== 'font-size') return;
      const m = /^(\d*\.?\d+)px$/.exec(decl.value);  // only sees px values
      if (m && Number(m[1]) < 12) decl.value = '12px';
    },
  });
  minFont.postcss = true;

  const pxToRem = () => ({
    postcssPlugin: 'px-to-rem',
    Declaration(decl) {
      decl.value = decl.value.replace(/(\d*\.?\d+)px/g, (m, n) => (n / 16) + 'rem');
    },
  });
  pxToRem.postcss = true;

  const all = { 'min-font': minFont, 'px-to-rem': pxToRem };
  let order = ['min-font', 'px-to-rem'];
  let postcss;

  const list = document.getElementById('list');
  const out = document.getElementById('out');

  async function render() {
    list.innerHTML = order.map((n, i) => '<div class="item"><span>' + (i + 1) + '</span><b>' + n + '</b></div>').join('');
    if (!postcss) return;
    try {
      const result = await postcss(order.map((n) => all[n])).process(input, { from: undefined });
      out.className = '';
      out.textContent = result.css;
    } catch (e) {
      out.className = 'err';
      out.textContent = e.message;
    }
  }

  document.getElementById('swap').addEventListener('click', () => { order.reverse(); render(); });
  render();
  import('https://cdn.jsdelivr.net/npm/postcss@8.5.28/+esm')
    .then((m) => { postcss = m.default; render(); })
    .catch(() => { out.className = 'err'; out.textContent = 'Could not load PostCSS from the CDN.'; });
</script>
</body>
</html>
Swap the two plugins and compare the output for the same 11px font size.
The same two plugins in two orders: a 12px floor applied before the rem conversion, then after it.
The same two plugins in two orders: a 12px floor applied before the rem conversion, then after it.

Here the font-size floor only understands px values. When it runs first, 11px becomes 12px, then 0.75rem. When rem conversion runs first, the floor no longer sees px and leaves 0.6875rem alone.

A finished example: source CSS in, plain CSS out

This page keeps variables, px sizes and comments in the source CSS, then ships plain CSS to the badge below it. Switch plugins off to see the raw source take over.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PostCSS live preview</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; color: #1d2330; background: #fff; }
  h1 { font-size: 16px; margin: 0 0 8px; }
  .opts { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-bottom: 10px; font-size: 13px; }
  .opts label { display: flex; align-items: center; gap: 5px; cursor: pointer; }
  .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  @media (max-width: 520px) { .cols { grid-template-columns: 1fr; } }
  .lab { display: block; font: 700 12px/1 ui-monospace, Consolas, monospace; margin-bottom: 5px; color: #374151; }
  #src, #out {
    width: 100%; height: 200px; margin: 0; padding: 10px; border: 1px solid #d5d9e0; border-radius: 8px;
    font: 12.5px/1.5 ui-monospace, Consolas, monospace; background: #fafbfc; color: #1d2330; overflow: auto;
  }
  #src { resize: vertical; }
  #out { background: #f0fdf4; border-color: #bbf7d0; white-space: pre-wrap; }
  #out.err { background: #fff7ed; border-color: #fdba74; color: #9a3412; }
  .stage { margin-top: 12px; padding: 16px; border: 1px dashed #c3c9d4; border-radius: 10px; background: #f6f7f9; }
</style>
<style id="live"></style>
</head>
<body>
<h1>Write source CSS, ship plain CSS</h1>
<div class="opts">
  <label><input type="checkbox" id="vars" checked> $variables</label>
  <label><input type="checkbox" id="rem" checked> px to rem</label>
  <label><input type="checkbox" id="pre" checked> copy user-select</label>
  <label><input type="checkbox" id="cm" checked> strip comments</label>
</div>
<div class="cols">
  <div><label class="lab" for="src">input.css</label>
<textarea id="src" spellcheck="false">/* brand colour, set once */
$brand: #2563eb;
$radius: 12px;

.badge {
  padding: 12px 20px;
  border-radius: $radius;
  background: $brand;
  color: #fff;
  font-size: 18px;
  user-select: none;
}</textarea></div>
  <div><label class="lab">result.css</label><pre id="out">Loading PostCSS…</pre></div>
</div>
<div class="stage"><span class="badge">The result.css styles this badge</span></div>

<script>
  const src = document.getElementById('src');
  const out = document.getElementById('out');
  const live = document.getElementById('live');

  // $name: value at the top level is stored, removed, and substituted below
  const vars = () => ({
    postcssPlugin: 'dollar-vars',
    Once(root) {
      const map = {};
      root.walkDecls((d) => { if (d.prop[0] === '$') { map[d.prop] = d.value; d.remove(); } });
      root.walkDecls((d) => { d.value = d.value.replace(/\$[\w-]+/g, (k) => map[k] || k); });
    },
  });
  const rem = () => ({
    postcssPlugin: 'px-to-rem',
    Declaration(d) { d.value = d.value.replace(/(\d*\.?\d+)px/g, (m, n) => (n / 16) + 'rem'); },
  });
  const pre = () => ({
    postcssPlugin: 'copy-user-select',
    Declaration(d) {
      if (d.prop === 'user-select') d.cloneBefore({ prop: '-webkit-user-select' });
    },
  });
  const cm = () => ({
    postcssPlugin: 'strip-comments',
    Once(root) { root.walkComments((c) => c.remove()); },
  });
  [vars, rem, pre, cm].forEach((p) => { p.postcss = true; });
  const plugins = { vars, rem, pre, cm };

  let postcss;
  async function run() {
    if (!postcss) return;
    const chosen = Object.keys(plugins).filter((k) => document.getElementById(k).checked).map((k) => plugins[k]);
    try {
      const result = await postcss(chosen).process(src.value, { from: undefined });
      out.className = '';
      out.textContent = result.css;
      live.textContent = result.css;
    } catch (e) {
      out.className = 'err';
      out.textContent = e.message;
    }
  }

  src.addEventListener('input', run);
  document.querySelectorAll('.opts input').forEach((c) => c.addEventListener('change', run));
  import('https://cdn.jsdelivr.net/npm/postcss@8.5.28/+esm')
    .then((m) => { postcss = m.default; run(); })
    .catch(() => { out.className = 'err'; out.textContent = 'Could not load PostCSS from the CDN.'; });
</script>
</body>
</html>
Four small plugins: dollar variables, px to rem, a copied -webkit-user-select line, and comment stripping. The badge uses the output.
  • Variables: a Once listener collects $name: value lines, removes them, and substitutes the values.
  • Copy a declaration: cloneBefore inserts a prefixed twin of user-select. This is a demo plugin, not Autoprefixer.
  • Comments: root.walkComments removes each comment node.

When it does not work

What you see Cause Fix
Cannot find module for a plugin The plugin is in the config but not installed Install it with npm
The config seems ignored in a build tool The file name is not one the loader looks for Use postcss.config.js or another supported name
A plugin does nothing The function lacks postcss = true Add that line after the function
Output is empty or old process was not awaited Use await or .then before reading css
Unclosed block in the message The input CSS has a missing brace Close the block; the message gives line and column
Unexpected token on import Static import in a normal script Use dynamic import() or type="module"
Output differs after reordering Plugins see each other's output Put the plugin that needs px before the one that removes px

A plugin demo is better felt than described. A screenshot cannot take a typed rule, and a build setup cannot be sent to someone who has no Node. A single page can, because it needs nothing but a browser.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, including the one that loads PostCSS from the CDN, so the people you send it to can edit the CSS themselves. If you change the code later, the same link shows the new version.

If you came for utility classes rather than PostCSS, adding Tailwind to an HTML file is the matching single-file recipe. For where a <style> tag goes, see the style tag.

Questions people ask

What does a postcss.config.js file look like?

It exports an object with a plugins list. A common form is module.exports = { plugins: [require('autoprefixer')] }. PostCSS tools look for the file by name, so it sits in the project and the build tool picks it up.

Where does postcss.config.js go, and what else is it called?

The loader that reads it, postcss-load-config, accepts postcss.config.js and its .cjs, .mjs and TypeScript variants, the .postcssrc names, a .postcssrc file in JSON or YAML, or a postcss section in package.json.

Can PostCSS run in the browser?

Yes, the demos on this page do it. PostCSS is normally run by Node in a build step, but you can import it from a CDN and call postcss(plugins).process(css) inside a page. You then put result.css into a style tag.

Is Autoprefixer the same thing as PostCSS?

No. Autoprefixer is a plugin that runs on PostCSS. It adds vendor prefixes to CSS rules using data from Can I Use, and it reads the browsers you target through Browserslist.

Do I need postcss.config.js in a single HTML file?

No. The config file is how build tools find your plugin list. In a page you pass the list yourself: postcss([pluginA, pluginB]). Nothing reads a file.

Keep reading