Rich text toolbar

서식 툴바

A row of toggle buttons — bold, italic, underline — that switch formatting on and off for the current selection.

Also known as: Formatting toolbarWYSIWYG toolbar
···
html
<div class="rtw">
  <div class="rttb" role="toolbar" aria-label="서식" aria-orientation="horizontal" id="rttb">
    <button aria-pressed="true" aria-label="굵게" tabindex="0"><b>B</b></button>
    <button aria-pressed="false" aria-label="기울임" tabindex="-1"><i>I</i></button>
    <button aria-pressed="false" aria-label="밑줄" tabindex="-1"><u>U</u></button>
    <button aria-pressed="false" aria-label="취소선" tabindex="-1"><s>S</s></button>
  </div>
  <p class="rtprev">문장 일부에 <span id="rtsample">서식</span>이 적용됩니다.</p>
</div>
css
.rtw{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px}
.rttb{display:flex;gap:3px;border:1px solid var(--line);border-radius:9px;background:var(--surface);padding:4px}
.rttb button{width:26px;height:26px;border:0;border-radius:6px;background:none;color:var(--fg);font-size:12px;cursor:default}
.rttb button[aria-pressed=true]{background:var(--accent);color:#fff}
.rtprev{font-size:12px;color:var(--fg);max-width:220px;text-align:center;line-height:1.6}
js
const btns = [...document.querySelectorAll('#rttb button')];
const sample = document.getElementById('rtsample');
const CODES = ['B', 'I', 'U', 'S'];
const STYLES = [['B'], ['I'], ['U'], ['B', 'I'], ['S'], []];
function apply(codes) {
  btns.forEach((b, i) => b.setAttribute('aria-pressed', String(codes.includes(CODES[i]))));
  sample.style.fontWeight = codes.includes('B') ? '800' : '400';
  sample.style.fontStyle = codes.includes('I') ? 'italic' : 'normal';
  sample.style.textDecoration = [codes.includes('U') ? 'underline' : '', codes.includes('S') ? 'line-through' : ''].filter(Boolean).join(' ');
}
let auto = true, focusIdx = 0;
btns.forEach((b, i) => {
  b.addEventListener('click', () => {
    auto = false;
    b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));
    const active = btns.map((x, xi) => (x.getAttribute('aria-pressed') === 'true' ? CODES[xi] : null)).filter(Boolean);
    apply(active);
  });
});
document.getElementById('rttb').addEventListener('keydown', (e) => {
  if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
  auto = false; e.preventDefault();
  btns[focusIdx].tabIndex = -1;
  focusIdx = (focusIdx + (e.key === 'ArrowRight' ? 1 : -1) + btns.length) % btns.length;
  btns[focusIdx].tabIndex = 0; btns[focusIdx].focus();
});
let si = 0;
setInterval(() => { if (!auto) return; si = (si + 1) % STYLES.length; apply(STYLES[si]); }, 1000);

Each button announces its state with aria-pressed="true"/"false" — a different value from the aria-expanded a menu item uses, or the aria-checked a checkable menu item uses, because it expresses only two plain states: pressed or not.

It's a specific case of the general pattern covered by this dictionary's "Toolbar" entry, inheriting the same roving-tabindex and arrow-key rules. What sets a formatting toolbar apart is that button state isn't fixed — every time the caret or selection moves, the buttons must recompute "is the spot under the cursor bold?" and resync their pressed state. It's nothing like a menubar — a menubar item opens a submenu when pressed, while a formatting toolbar's buttons toggle their state right where they are, with no submenu (except for an item that genuinely needs a popup, like inserting a link).

The whole button group should be a single Tab stop — arrow keys move between buttons (roving tabindex), and Tab skips the entire toolbar to land on the next widget, the editable content area.