CSS tab-size: control how wide a tab is

A tab character in a pre or textarea is 8 spaces wide unless you say otherwise. One CSS property, tab-size, sets it, and it changes only how tabs look, never the text.

Set tab-size on the element that shows the tabs. pre { tab-size: 4; } makes every tab character four spaces wide instead of the default eight. It works the same on a <textarea>, and it changes only how tabs are drawn, not the text inside.

Try it. Every indent in this code is one real tab character. Press the buttons to change the width.

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>tab-size basics</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 10px; }
  .bar button {
    font: 600 14px system-ui, sans-serif; padding: 7px 14px; border-radius: 8px;
    border: 1px solid #cfd5de; background: #fff; cursor: pointer;
  }
  .bar button[aria-pressed="true"] { background: #1d4ed8; border-color: #1d4ed8; color: #fff; }
  pre {
    margin: 0; padding: 14px 16px; border-radius: 10px; background: #1e2433; color: #e6e9ef;
    font: 15px/1.5 ui-monospace, Consolas, monospace;
    overflow-x: auto;
    tab-size: 4;  /* one tab = the width of 4 spaces (the default is 8) */
  }
  .rule { margin-top: 10px; font: 14px ui-monospace, Consolas, monospace; color: #374151; }
</style>
</head>
<body>
<div class="bar" id="bar">
  <span>Tab width:</span>
  <button data-size="2" aria-pressed="false">2</button>
  <button data-size="4" aria-pressed="true">4</button>
  <button data-size="8" aria-pressed="false">8</button>
</div>

<!-- every indent below is a real tab character -->
<pre id="code">function greet(name) {
	if (name) {
		return 'Hello, ' + name;
	}
	return 'Hello';
}</pre>

<p class="rule" id="rule">pre { tab-size: 4; }</p>

<script>
  const code = document.getElementById('code');
  const rule = document.getElementById('rule');

  document.getElementById('bar').addEventListener('click', (e) => {
    const btn = e.target.closest('button');
    if (!btn) return;
    code.style.tabSize = btn.dataset.size;  // same text, different tab width
    rule.textContent = 'pre { tab-size: ' + btn.dataset.size + '; }';
    document.querySelectorAll('#bar button').forEach((b) => b.setAttribute('aria-pressed', b === btn));
  });
</script>
</body>
</html>
One tab-indented function. The buttons change tab-size, and the text stays the same.

The code is identical in all three states. Only the drawn width of each tab changes, so a copy of the code still contains tabs.

The value: a number or a length

A plain number counts space characters in the element's own font. tab-size: 4 is as wide as four spaces, so it grows with font-size. A length such as tab-size: 20px or tab-size: 2em sets the width directly.

Value What a tab is Notes
8 8 space widths The initial value
4 or 2 4 or 2 space widths Narrower indents for code
20px 20 pixels Does not follow the space width
2em Twice the font size Scales with the font
0 No width Tabs take no space

Negative values are invalid, and the browser ignores the declaration. The property is inherited, which means one rule on body or on pre also covers the <code> inside it.

A tab jumps to the next tab stop

A tab is not a fixed gap. tab-size: 4 places tab stops every four columns, and each tab moves the text to the next stop after the point where it starts.

With tab-size: 4, a tab after two letters is two columns wide. After three letters, it is one.
With tab-size: 4, a tab after two letters is two columns wide. After three letters, it is one.

This is why tabs line up columns of text. In the code viewer at the end, the values after red:, green: and blue: start at the same column when the width is 4 or 8, because each tab stops at the same place.

The stops are counted from the start of the block, not from the tab itself.

Text that runs past a stop pushes its tab on to the following one. At width 2, green: ends one column later than red:, so its value lands on a different stop.

tab-size needs white-space that keeps tabs

tab-size sizes tabs that survive layout. In a normal paragraph, runs of spaces and tabs collapse into one space, and there is no tab left to size.

pre, pre-wrap and break-spaces keep tabs. pre-line, normal and nowrap turn them into one space.
pre, pre-wrap and break-spaces keep tabs. pre-line, normal and nowrap turn them into one space.
  • <pre> uses white-space: pre by default, so tabs are kept.
  • <textarea> uses white-space: pre-wrap by default. Tabs are kept and long lines wrap.
  • A <div> or <code> inside a paragraph uses normal. Add white-space: pre or pre-wrap first.

The white-space guide compares every value in detail. For the elements themselves, see code and pre in HTML.

Tabs vs spaces: what tab-size can and cannot change

tab-size changes tab characters only. Code indented with spaces looks the same at every setting. Code that mixes both is the real trap.

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>Tabs vs spaces</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px 14px; margin-bottom: 12px; font-size: 14px; }
  select { font: 14px system-ui, sans-serif; padding: 4px 6px; }
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; }
  h3 { margin: 0 0 6px; font-size: 14px; }
  .box {
    margin: 0; padding: 10px 12px; border-radius: 9px; background: #fff; border: 1px solid #dfe3e8;
    font: 14px/1.5 ui-monospace, Consolas, monospace; overflow-x: auto;
  }
  .tab { background: #fde2da; }    /* tab characters, made visible */
  .sp { background: #dbeafe; }     /* space characters */
  @media (max-width: 500px) { .box { font-size: 13px; line-height: 1.35; padding: 8px 10px; } .grid { gap: 8px; } }
  .key { font-size: 13px; color: #4b5563; margin-top: 10px; }
  .key span { padding: 0 6px; border-radius: 3px; }
</style>
</head>
<body>
<div class="bar">
  <label>tab-size
    <select id="size"><option>2</option><option selected>4</option><option>8</option></select>
  </label>
  <label>white-space
    <select id="ws">
      <option>pre</option><option>pre-wrap</option><option>pre-line</option><option>normal</option>
    </select>
  </label>
</div>

<div class="grid">
  <div><h3>Indented with tabs</h3>
<pre class="box" id="tabs">function sum(list) {
	let total = 0;
	for (const n of list) {
		total += n;
	}
	return total;
}</pre></div>
  <div><h3>Indented with 4 spaces</h3>
<pre class="box" id="spaces">function sum(list) {
    let total = 0;
    for (const n of list) {
        total += n;
    }
    return total;
}</pre></div>
  <div><h3>Mixed: tabs and spaces</h3>
<pre class="box" id="mixed">function sum(list) {
	let total = 0;
    for (const n of list) {
		total += n;
    }
	return total;
}</pre></div>
</div>
<p class="key"><span class="tab">tab</span> <span class="sp">space</span> Only the tabs change width.</p>

<script>
  const boxes = document.querySelectorAll('.box');

  // wrap each tab and each run of spaces in a span, so you can see them
  boxes.forEach((box) => {
    box.innerHTML = box.textContent
      .replace(/&/g, '&amp;').replace(/</g, '&lt;')
      .replace(/\t/g, '<span class="tab">\t</span>')
      .replace(/ {2,}/g, (s) => '<span class="sp">' + s + '</span>');
  });

  function apply() {
    boxes.forEach((box) => {
      box.style.tabSize = document.getElementById('size').value;
      box.style.whiteSpace = document.getElementById('ws').value;
    });
  }
  document.getElementById('size').addEventListener('change', apply);
  document.getElementById('ws').addEventListener('change', apply);
  apply();
</script>
</body>
</html>
Tabs are highlighted in orange, runs of spaces in blue. Try tab-size 2 and 8, then switch white-space to normal.

In the mixed box, one line starts with a tab and the next with four spaces. They line up only when tab-size is exactly 4. At 2 or 8 the tab lines move and the space lines stay, so the nesting looks wrong.

Mixed indentation lines up at one tab width only. Tabs alone stay consistent at every width.
Mixed indentation lines up at one tab width only. Tabs alone stay consistent at every width.
Indented with tab-size 2 tab-size 8 Reader can pick a width
Tabs only Narrow, correct Wide, correct Yes
Spaces only Unchanged Unchanged No
Tabs and spaces Misaligned Misaligned No

If the code comes from somewhere you do not control, converting tabs to spaces is the reliable fix. The code viewer below has a button that does it.

Typing a tab in a textarea

In a <textarea>, the Tab key moves focus to the next control. That is the right default for forms. For a code box, you can catch the key and insert a tab instead:

textarea.addEventListener('keydown', (e) => {
  if (e.key !== 'Tab' || e.shiftKey) return;
  e.preventDefault();
  const { selectionStart: s, selectionEnd: end } = textarea;
  textarea.setRangeText('\t', s, end, 'end');
});

setRangeText replaces the selection and puts the caret after the tab. It does not fire an input event, so call your own update function after it.

Taking over Tab traps keyboard users inside the box. The example below keeps an exit: press Esc, and the next Tab moves focus as usual. Keyboard focus and tabindex covers focus order in general, and the textarea guide covers the element itself.

A finished example: a code viewer with a tab width picker

This viewer puts the pieces together. One CSS variable drives tab-size for both the editor and the viewer, and a <select> changes 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>Code viewer with a tab width selector</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; margin-bottom: 10px; font-size: 14px; }
  select, button { font: 14px system-ui, sans-serif; padding: 5px 8px; }
  button { border-radius: 7px; border: 1px solid #cfd5de; background: #fff; cursor: pointer; }
  label.t { font-size: 13px; color: #4b5563; display: block; margin: 8px 0 4px; }
  textarea, pre {
    box-sizing: border-box; width: 100%; margin: 0; padding: 10px 12px; border-radius: 9px;
    font: 14px/1.5 ui-monospace, Consolas, monospace;
    tab-size: var(--tab, 4);  /* one variable drives both boxes */
  }
  textarea { height: 150px; border: 1px solid #cfd5de; resize: vertical; }
  pre { background: #1e2433; color: #e6e9ef; min-height: 60px; white-space: pre; overflow-x: auto; }
  pre.wrap { white-space: pre-wrap; }
  pre.show .tab { background: rgba(251, 146, 60, .35); }
  .hint { font-size: 12.5px; color: #6b7280; margin: 6px 0 0; }
</style>
</head>
<body>
<div class="bar">
  <label>Tab width
    <select id="size"><option>2</option><option>3</option><option selected>4</option><option>8</option></select>
  </label>
  <label><input type="checkbox" id="wrap"> Wrap lines</label>
  <label><input type="checkbox" id="show" checked> Show tabs</label>
  <button id="convert">Tabs to spaces</button>
</div>

<label class="t" for="ed">Edit (the Tab key types a tab. Press Esc, then Tab, to leave the box.)</label>
<textarea id="ed" spellcheck="false">const colors = {
	red:	'#e11d48',
	green:	'#16a34a',
	blue:	'#2563eb',
};

if (ready) {
	paint(colors.red);
}</textarea>

<label class="t">Viewer</label>
<pre id="view" class="show"></pre>
<p class="hint" id="count"></p>

<script>
  const ed = document.getElementById('ed');
  const view = document.getElementById('view');
  const size = document.getElementById('size');

  function render() {
    // escape the text, then wrap each tab in a span so it can be highlighted
    view.innerHTML = ed.value
      .replace(/&/g, '&amp;').replace(/</g, '&lt;')
      .replace(/\t/g, '<span class="tab">\t</span>');
    const tabs = (ed.value.match(/\t/g) || []).length;
    document.getElementById('count').textContent = tabs + ' tab characters in the text';
  }

  // tab-size on the page follows the select
  size.addEventListener('change', () => {
    document.body.style.setProperty('--tab', size.value);
  });
  document.getElementById('wrap').addEventListener('change', (e) => view.classList.toggle('wrap', e.target.checked));
  document.getElementById('show').addEventListener('change', (e) => view.classList.toggle('show', e.target.checked));

  // Tab types a tab. Esc first lets the next Tab move focus as usual.
  let leaving = false;
  ed.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') { leaving = true; return; }
    if (e.key !== 'Tab' || e.shiftKey || leaving) { leaving = false; return; }
    e.preventDefault();
    ed.setRangeText('\t', ed.selectionStart, ed.selectionEnd, 'end');
    render();
  });
  ed.addEventListener('input', render);

  // replace each tab with spaces up to the next tab stop
  document.getElementById('convert').addEventListener('click', () => {
    const n = Number(size.value);
    ed.value = ed.value.split('\n').map((line) => {
      let out = '';
      for (const ch of line) {
        out += ch === '\t' ? ' '.repeat(n - (out.length % n)) : ch;
      }
      return out;
    }).join('\n');
    render();
  });

  render();
</script>
</body>
</html>
Pick a tab width, type tabs in the editor, highlight them in the viewer, or convert them to spaces.

The core is one line of CSS and one line of JavaScript:

textarea, pre { tab-size: var(--tab, 4); }
document.body.style.setProperty('--tab', select.value);
  • Wrap lines switches the viewer from pre to pre-wrap. Tabs keep their width either way.
  • Show tabs wraps each tab in a <span> with a background. The span is still sized by the tab stops of the <pre>.
  • Tabs to spaces walks each line and replaces every tab with enough spaces to reach the next stop, so the alignment stays exactly as it was.

The conversion must count columns. Replacing each tab with a fixed four spaces breaks the aligned values:

out += ch === '\t' ? ' '.repeat(n - (out.length % n)) : ch;

When it does not work

What you see Cause Fix
Tabs are still very wide tab-size is not set, so the default of 8 applies tab-size: 4 on the pre or a parent
tab-size has no effect white-space collapses the tabs Use pre, pre-wrap or break-spaces
Some lines shift, others do not The code mixes tabs and spaces Convert to one kind of indent
The indent looks the same at every setting The code uses spaces, not tabs Nothing to size. Convert spaces to tabs if you want a picker
Tab key leaves the textarea Default focus behavior Handle keydown and insert a tab
Aligned values drift after converting Each tab became a fixed number of spaces Pad to the next tab stop

A code sample with a tab width picker is easier to try than to describe. A screenshot shows one width only, and an .html attachment may open as plain text on a phone.

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 change the tab width and type in the editor themselves. If you change the code later, the same link shows the new version.

Questions people ask

What is the default tab size in CSS?

The initial value of tab-size is 8. A tab in a pre or a textarea moves the text to the next multiple of 8 space widths until you set a smaller number.

Why does tab-size do nothing on my element?

The tabs are probably being collapsed. With white-space: normal, nowrap or pre-line, a tab is treated like a space and squeezed into one gap, so there is no tab left to size. Use pre, pre-wrap or break-spaces, or put the text in a pre element.

Does tab-size change the text when it is copied?

No. tab-size only changes how wide a tab is drawn on screen. The text still contains the same tab characters. To replace tabs with spaces you have to change the text itself, for example with a small script.

Can tab-size take a pixel value?

Yes. A plain number means that many space widths in the element's font, and a length such as 20px or 2em sets the width directly. Negative values are invalid and ignored. tab-size: 0 makes tabs take no space at all.

How do I type a tab inside a textarea?

By default the Tab key moves focus to the next control. To type a tab, listen for keydown, call preventDefault and insert the character with setRangeText. Leave keyboard users a way out, such as pressing Esc before Tab.

Keep reading