WordPress custom HTML and CSS: test it in one file, then paste it in

Here WordPress means the website software. Its Custom HTML block takes your markup and its Additional CSS box takes your styles, so a single HTML file you tested first splits cleanly into both.

Here WordPress means the website software, and "custom HTML and CSS" means two boxes. The Custom HTML block holds your markup. Additional CSS holds your styles.

The quickest way to get both right is to build the widget in one ordinary HTML file first, test it in a browser, then split it into those two places.

Here is a small widget written that way. Click the button, then read the three marked parts in the code.

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>One-file widget</title>
<style>
  /* PART 1 - CSS: later goes into Additional CSS */
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .nc-box {
    max-width: 340px; padding: 16px 18px; border-radius: 12px;
    background: #fff; box-shadow: 0 6px 20px rgba(0, 0, 0, .12);
  }
  .nc-box h3 { margin: 0 0 6px; font-size: 18px; }
  .nc-box p { margin: 0 0 12px; color: #444; }
  .nc-box button {
    padding: 9px 16px; border: 0; border-radius: 8px;
    background: #1d4ed8; color: #fff; font: inherit; cursor: pointer;
  }
  .nc-box button.on { background: #15803d; }
</style>
</head>
<body>

<!-- PART 2 - HTML: later goes into the Custom HTML block -->
<div class="nc-box">
  <h3>Saturday walk</h3>
  <p id="nc-count">12 people are going.</p>
  <button type="button" id="nc-join">I'm in</button>
</div>

<!-- PART 3 - JavaScript: after the markup it uses -->
<script>
  const join = document.getElementById('nc-join');
  const count = document.getElementById('nc-count');
  let going = 12;

  join.addEventListener('click', () => {
    join.classList.toggle('on');
    going += join.classList.contains('on') ? 1 : -1;
    join.textContent = join.classList.contains('on') ? 'Going - undo' : "I'm in";
    count.textContent = going + ' people are going.';
  });
</script>
</body>
</html>
One file with three parts: a style block, the markup, and a script. Edit the code and the example reruns.

The class names all start with nc-. That is a habit worth keeping, and the reason is further down.

The three places your code goes

A single file mixes three jobs. WordPress gives each job its own home.

One HTML file on the left, and the three WordPress places its parts go to on the right.
One HTML file on the left, and the three WordPress places its parts go to on the right.
  • The CSS goes into Additional CSS, which WordPress describes as a panel for CSS that overrides the current theme.
  • The markup goes into a Custom HTML block, which the block editor's reference describes as a block to add custom HTML code and preview it as you edit.
  • The script is the touchy part. Whether it survives depends on who saves the post, as the section on scripts explains.

Add the HTML with the Custom HTML block

The block is named core/html. Add it from the editor:

  1. Click the Block Inserter icon, or type /html in a new paragraph and press Enter.
  2. Click Edit HTML to open the editing window.
  3. Paste the code. A preview of the output appears to the right of the code.
  4. Press Update.

Paste only what was inside the <body> tag. The <html> element is the root of a document, and every other element must sit inside it. Your block already sits inside a page, so the wrapper tags do not belong in it.

Add the CSS with Additional CSS

Where the box lives depends on the kind of theme.

With a theme that supports the Customizer, choose Appearance, then Customize, then Additional CSS. The Customizer previews changes before you publish them, and WordPress notes that changes made there are kept, whereas edits to theme files can be lost when the theme updates.

WordPress says a block theme hides the Customizer, so there you work in the Site Editor. Open Appearance, then Editor, open Styles, and choose Additional CSS from the three-dot menu. This has existed since WordPress 6.2.

One detail from the Styles documentation: CSS added there is not overwritten by a theme update, but it is cleared when you switch themes. Keep your tested file as the master copy.

Keep your CSS from changing the whole site

Your widget does not live alone. The page around it already has the theme's styles, and your Additional CSS applies to the entire site, not just to the block.

So a rule written for the widget can hit things you never meant to touch. Try it: switch between the two selectors and watch the theme's own buttons.

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>Selector scope</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #eef0f3; color: #1d2330; }
  .pick { display: flex; flex-wrap: wrap; gap: 8px 18px; margin-bottom: 10px; font-size: 14px; }
  .pick label { cursor: pointer; }
  pre { margin: 0 0 12px; padding: 8px 10px; border-radius: 8px; background: #1d2330; color: #e8ecf3; font: 13px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; }
  .page { background: #fff; border-radius: 10px; overflow: hidden; border: 1px solid #d9dde4; }
  .bar { display: flex; gap: 8px; padding: 10px 12px; background: #f7f8fa; border-bottom: 1px solid #e3e6ec; font-size: 13px; align-items: center; }
  .bar span { margin-right: auto; color: #6b7280; }
  .main { padding: 14px 12px; }
  .nc-box { padding: 12px 14px; border: 2px dashed #9aa3b2; border-radius: 10px; }
  .nc-box p { margin: 0 0 10px; }
  .note { margin: 10px 0 0; font-size: 13px; color: #374151; }
</style>
<!-- Stands in for the theme's stylesheet: it styles every button on the site -->
<style id="theme">
  button { padding: 7px 14px; border: 1px solid #1d2330; border-radius: 0; background: #fff; color: #1d2330; font: inherit; cursor: pointer; }
</style>
<!-- Stands in for your Additional CSS: swapped by the radio buttons -->
<style id="mine"></style>
</head>
<body>

<div class="pick">
  <label><input type="radio" name="scope" value="generic" checked> Generic selector</label>
  <label><input type="radio" name="scope" value="scoped"> Scoped selector</label>
</div>
<pre id="rule"></pre>

<div class="page">
  <div class="bar"><span>Theme header</span><button type="button">Menu</button><button type="button">Search</button></div>
  <div class="main">
    <div class="nc-box">
      <p>My widget</p>
      <button type="button">Join</button>
    </div>
    <p class="note" id="note"></p>
  </div>
</div>

<script>
  const mine = document.getElementById('mine');
  const rule = document.getElementById('rule');
  const note = document.getElementById('note');
  const body = 'background: #e8590c; color: #fff; border-color: #e8590c; border-radius: 20px;';
  const options = {
    generic: { sel: 'button', text: 'Every button on the page changed, including the theme\'s Menu and Search.' },
    scoped:  { sel: '.nc-box button', text: 'Only the button inside the widget changed.' }
  };

  function show(key) {
    const o = options[key];
    const css = o.sel + ' { ' + body + ' }';
    mine.textContent = css;
    rule.textContent = css;
    note.textContent = o.text;
  }

  document.querySelectorAll('input[name="scope"]').forEach((r) => {
    r.addEventListener('change', () => show(r.value));
  });
  show('generic');
</script>
</body>
</html>
The theme styles every button. A generic button rule restyles them all; a scoped rule restyles only the widget.
Left: a generic button selector restyles the theme's buttons too. Right: a class-scoped selector touches only the widget.
Left: a generic button selector restyles the theme's buttons too. Right: a class-scoped selector touches only the widget.

The fix is the nc- habit. Start every rule with a class that only your markup carries, such as .nc-box button.

A class selector also outweighs a plain type selector such as button, so if a theme rule beats yours, the scoped version usually wins without any extra tricks.

Why a script can disappear after saving

WordPress documents that users without the unfiltered_html capability, a Contributor for example, get their Custom HTML sanitized with wp_kses(). That function allows only the permitted tags and attributes, and the documentation names <script> and <iframe> as examples of what gets stripped.

The same block saved by a user with the capability and a user without it.
The same block saved by a user with the capability and a user without it.

The capability allows a user to post HTML markup or even JavaScript in pages, posts, comments and widgets. On a single site, Administrators and Editors have it by default. On Multisite, only a Super Admin does.

The documentation also says that starting in WordPress 7.0 the block has separate HTML, CSS and JavaScript panels, and that the CSS and JavaScript panels appear only for users with the capability.

If you maintain a theme or plugin, WordPress recommends loading scripts and styles through its standard method, shown here:

function add_theme_scripts() {
  wp_enqueue_style( 'style', get_stylesheet_uri() );
  wp_enqueue_script( 'script', get_template_directory_uri() . '/js/script.js', array( 'jquery' ), 1.1, true );
}
add_action( 'wp_enqueue_scripts', 'add_theme_scripts' );

That runs on the server, so it cannot be tried in a plain HTML file.

Split the file in one click

Copying three parts by hand invites mistakes. This page reads a full HTML file and sorts it into the three boxes, then lists what needs attention.

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>Split a page into WordPress parts</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  h1 { margin: 0 0 4px; font-size: 18px; }
  .hint { margin: 0 0 10px; font-size: 13px; color: #4b5563; }
  label { display: block; margin: 10px 0 4px; font-size: 13px; font-weight: 700; }
  label small { font-weight: 400; color: #4b5563; }
  textarea {
    box-sizing: border-box; width: 100%; padding: 8px 10px; border: 1px solid #c9ced8; border-radius: 8px;
    font: 12.5px/1.45 ui-monospace, Consolas, monospace; background: #fff; color: #1d2330; resize: vertical;
  }
  #src { height: 150px; }
  .out { height: 96px; background: #fafbfc; }
  ul { margin: 12px 0 0; padding: 0; list-style: none; font-size: 13px; line-height: 1.45; }
  li { margin: 0 0 6px; padding: 7px 10px; border-radius: 8px; background: #fff; border-left: 4px solid #15803d; }
  li.warn { border-left-color: #d9480f; }
</style>
</head>
<body>
<h1>Split a page into WordPress parts</h1>
<p class="hint">Paste a whole HTML file, or edit the sample. Nothing is sent anywhere.</p>

<label for="src">Your single HTML file</label>
<textarea id="src" spellcheck="false">&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;style&gt;
  .nc-box { padding: 14px; border-radius: 10px; background: #fff; }
  .nc-box button { padding: 8px 14px; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div class="nc-box"&gt;
  &lt;p id="nc-count"&gt;12 people are going.&lt;/p&gt;
  &lt;button type="button" id="nc-join"&gt;I'm in&lt;/button&gt;
&lt;/div&gt;
&lt;script&gt;
  document.getElementById('nc-join').addEventListener('click', () =&gt; {
    document.getElementById('nc-count').textContent = '13 people are going.';
  });
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</textarea>

<label for="html">Custom HTML block <small>(the body contents)</small></label>
<textarea id="html" class="out" readonly spellcheck="false"></textarea>

<label for="css">Additional CSS <small>(every style block)</small></label>
<textarea id="css" class="out" readonly spellcheck="false"></textarea>

<label for="js">JavaScript <small>(every inline script)</small></label>
<textarea id="js" class="out" readonly spellcheck="false"></textarea>

<ul id="notes"></ul>

<script>
  const $ = (id) => document.getElementById(id);

  function split() {
    const src = $('src').value;
    // Parsing builds a document but does not run its scripts
    const doc = new DOMParser().parseFromString(src, 'text/html');

    const css = [...doc.querySelectorAll('style')].map((s) => s.textContent.trim());
    const inline = [...doc.querySelectorAll('script:not([src])')].map((s) => s.textContent.trim());
    const external = [...doc.querySelectorAll('script[src], link[rel~="stylesheet"]')]
      .map((el) => el.getAttribute('src') || el.getAttribute('href'));
    const frames = doc.querySelectorAll('iframe').length;
    const scripts = doc.querySelectorAll('script').length;

    const body = doc.body.cloneNode(true);
    body.querySelectorAll('style, script').forEach((el) => el.remove());

    $('html').value = body.innerHTML.trim();
    $('css').value = css.join('\n\n');
    $('js').value = inline.join('\n\n');

    const notes = [];
    if (/<(html|head|body)[\s>]/i.test(src)) {
      notes.push(['ok', 'The html, head and body wrapper tags are left out. The block sits inside a page that already has them.']);
    }
    if (css.length) notes.push(['ok', css.length + ' style block(s) moved to the CSS box.']);
    if (scripts) notes.push(['warn', scripts + ' script tag(s) found. WordPress strips script tags on save for users without the unfiltered_html capability.']);
    if (frames) notes.push(['warn', frames + ' iframe(s) found. These are stripped for users without unfiltered_html too.']);
    external.forEach((u) => notes.push(['warn', 'External file: ' + u + '. Check that it also loads on the WordPress page.']));
    if (!scripts && !frames) notes.push(['ok', 'No script or iframe tags found.']);

    $('notes').innerHTML = '';
    notes.forEach(([kind, text]) => {
      const li = document.createElement('li');
      if (kind === 'warn') li.className = 'warn';
      li.textContent = text;
      $('notes').appendChild(li);
    });
  }

  $('src').addEventListener('input', split);
  split();
</script>
</body>
</html>
Paste a whole HTML file. The page separates the body markup, the style blocks and the inline scripts.

It parses the text with DOMParser, which builds a document without running its scripts. The same warnings apply as above: a script or iframe in your file needs a user with the capability.

Everything on this page was tested in a plain browser file, not inside a WordPress install. After you paste, open the published page and check how your theme treats the result.

When it does not work

What you see Cause Fix
The script does nothing after saving The saving user lacks unfiltered_html Have a user with the capability add it
The iframe is gone iframe is stripped for the same reason Same fix
Buttons or links all over the site changed A generic selector in Additional CSS Scope with a class: .nc-box button
My rule loses to the theme The theme's selector is more specific Use a class-based selector
Additional CSS is not in the menu A block theme hides the Customizer Appearance, Editor, Styles, three-dot menu
Styles vanished after changing theme Site Editor CSS is cleared on a theme switch Re-paste from your tested file
Extra html, head and body tags inside the block The whole file was pasted Paste only the body contents

Before the widget goes into a live WordPress site, you may want someone else to try it. A screenshot cannot be clicked. A single working file can.

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, so the people you send it to can click the button themselves, without an account. If you change the code later, the same link shows the new version.

For the file habit behind this, see self-contained HTML files. If the whole site is the real goal, turning a managed site into static files covers that path.

Questions people ask

Where do I put custom HTML in WordPress?

Add a Custom HTML block to a post or page. Click the Block Inserter, or type /html in a new paragraph and press Enter. Click Edit HTML, enter the code, check the preview, and press Update.

Where do I put custom CSS in WordPress?

In Additional CSS. With a theme that supports the Customizer, open Appearance, then Customize, then Additional CSS. With a block theme, open the Site Editor, then Styles, then the three-dot menu, then Additional CSS.

Why did my script tag vanish after I saved?

WordPress documents that for a user without the unfiltered_html capability, the Custom HTML block is sanitized with wp_kses(), which strips disallowed tags such as script and iframe. Ask a user who has the capability, such as an Administrator on a single site, to add it.

Can I paste a whole HTML file into the Custom HTML block?

Paste only what was inside the body. The block lives inside a page that already has its own html, head and body elements, and an HTML document has just one root html element. Move style blocks to Additional CSS.

Why does my CSS change buttons and links I did not mean to touch?

A selector such as button matches every button on the site. Start each rule with a class that only your block uses, for example .nc-box button.

Keep reading