← JavaScript/Box Model: box-sizing, margin, overflow#169 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Box Model: box-sizing, margin, overflow

Ты ставишь элементу width: 200px, добавляешь padding: 20px — и он вдруг стал 240px. Или два блока с margin-bottom: 20px и margin-top: 30px — ожидаешь 50px между ними, а получаешь 30px. Или читаешь element.offsetWidth в JavaScript и получаешь не то, что думаешь. Box Model объясняет всё это.

Какую проблему решает

Браузер вычисляет размеры элементов по Box Model. JavaScript-разработчик работает с этими размерами постоянно: позиционирование, анимации, вычисление доступного пространства. Непонимание box-sizing, margin collapse, и разницы offsetWidth/clientWidth приводит к багам в лейауте.

На основе предыдущих уроков

  • CSS Units: px-значения в вычислениях box model
  • Flexbox/position: flex и grid отменяют margin collapse
  • DOM: element.offsetWidth, clientWidth, getBoundingClientRect
  • content-box vs border-box

    // content-box (исторический дефолт, ПЛОХОЙ):
    // width: 200px + padding: 20px + border: 2px
    // Итоговый offsetWidth = 200 + 40 + 4 = 244px  (НЕОЖИДАННО!)
    
    // border-box (современный стандарт, ХОРОШИЙ):
    // width: 200px включает padding И border
    // Итоговый offsetWidth = 200px  (предсказуемо!)
    // Контент = 200 - 40 - 4 = 156px
    
    // Сброс на border-box (ставь всегда в начале CSS):
    // *, *::before, *::after { box-sizing: border-box; }

    Margin collapse — схлопывание отступов

    // Вертикальные margin соседних блоков схлопываются:
    // .el1 { margin-bottom: 20px }
    // .el2 { margin-top: 30px }
    // Фактический отступ = max(20, 30) = 30px  (не 50!)
    
    // Схлопывание НЕ происходит:
    // - в flex/grid контейнерах
    // - при padding/border между родителем и первым/последним ребёнком
    // - для inline-block элементов
    // - горизонтальные margin НИКОГДА не схлопываются

    overflow — поведение содержимого

    // visible — выходит за границы (по умолчанию)
    // hidden  — обрезается
    // scroll  — всегда показывает скроллбар
    // auto    — скроллбар только при необходимости (рекомендуется)
    // clip    — как hidden, но отключает прокрутку через JS
    
    // overflow-x и overflow-y независимы:
    // overflow-x: hidden; overflow-y: auto;
    
    // Паттерны:
    // Прокручиваемый список:  max-height: 400px; overflow-y: auto;
    // Обрезка текста:         overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
    // Скрытие для анимации:   overflow: hidden; (+ transform)

    Размеры элементов в JavaScript

    // offsetWidth  = content + padding + border  (без margin)
    // clientWidth  = content + padding           (без border, без scrollbar)
    // scrollWidth  = полная ширина прокручиваемого контента
    
    // getBoundingClientRect() — точные размеры с дробями:
    // { width, height, top, left, right, bottom } — relative to viewport
    
    // Наглядно:
    // [margin | border | padding | content | padding | border | margin]
    //          ^-------- offsetWidth --------^
    //                   ^--- clientWidth ---^
    //                            ^content^
    
    // scrollWidth >= clientWidth (если есть горизонтальный скролл)

    CSS calc() для Box Model

    // Типичные паттерны:
    // height: calc(100vh - 64px)         — высота минус шапка
    // width: calc(100% - 2 * 16px)       — ширина минус отступы
    // width: calc(50% - 8px)             — в 2-колоночном grid с gap
    
    // Из JavaScript:
    element.style.width = `calc(100% - ${sidebarWidth}px)`

    Типичные ошибки

    Ошибка 1: Не использовать border-box

    /* ПЛОХО — неожиданные размеры */
    .input { width: 100%; padding: 12px 16px; }
    /* Ширина стала 100% + 32px горизонтальных padding! */
    
    /* ХОРОШО */
    *, *::before, *::after { box-sizing: border-box; }
    .input { width: 100%; padding: 12px 16px; }
    /* Ширина = ровно 100%, padding внутри */

    Ошибка 2: Удивляться margin collapse

    /* Ожидаешь 40px между параграфами, получаешь 20px */
    p { margin-top: 20px; margin-bottom: 20px; }
    /* Решение: */
    p + p { margin-top: 0; }
    /* или использовать padding вместо margin на контейнере */

    Ошибка 3: Путать offsetWidth и clientWidth

    // Для позиционирования (включая border):
    const fullWidth = element.offsetWidth
    
    // Для внутреннего контента (без border, без scrollbar):
    const innerWidth = element.clientWidth
    
    // Для проверки переполнения:
    const hasOverflow = element.scrollWidth > element.clientWidth

    В реальных проектах

  • Адаптивные инпуты: box-sizing: border-box + width: 100% — без сюрпризов
  • Прокручиваемые списки: max-height + overflow-y: auto для выпадающих меню
  • Виртуальный скролл: scrollHeight, scrollTop, clientHeight для расчёта видимых элементов
  • Resizable panels: offsetWidth для вычисления размеров при перетаскивании
  • Примеры

    Симуляция Box Model: вычисление размеров, margin collapse, overflow и scrollable контейнеры

    // Симуляция Box Model — чистые вычисления
    
    // ===== Вычисление размеров =====
    
    function calculateBoxModel({ width, height, padding, border, margin, scrollbarWidth = 0 }) {
      // --- content-box (исторический) ---
      const contentBox = {
        contentWidth:  width,
        contentHeight: height,
        offsetWidth:   width + 2 * padding + 2 * border,
        clientWidth:   width + 2 * padding - scrollbarWidth,
        totalWidth:    width + 2 * padding + 2 * border + 2 * margin,
      }
    
      // --- border-box (современный) ---
      const borderBox = {
        contentWidth:  width - 2 * padding - 2 * border,
        contentHeight: height - 2 * padding - 2 * border,
        offsetWidth:   width,                                    // width уже включает всё
        clientWidth:   width - 2 * border - scrollbarWidth,
        totalWidth:    width + 2 * margin,
      }
    
      return { contentBox, borderBox }
    }
    
    // === Демонстрация: content-box vs border-box ===
    console.log('=== content-box vs border-box (width=200, padding=20, border=2, margin=16) ===')
    const el = { width: 200, height: 100, padding: 20, border: 2, margin: 16 }
    const { contentBox, borderBox } = calculateBoxModel(el)
    
    console.log()
    console.log('Свойство'.padEnd(18) + 'content-box'.padEnd(14) + 'border-box')
    console.log('-'.repeat(44))
    const props = ['contentWidth', 'offsetWidth', 'clientWidth', 'totalWidth']
    for (const p of props) {
      console.log(p.padEnd(18) + String(contentBox[p]).padEnd(14) + String(borderBox[p]))
    }
    
    console.log()
    console.log('content-box: ожидаешь 200px, получаешь offsetWidth =', contentBox.offsetWidth, '(НЕОЖИДАННО!)')
    console.log('border-box:  width =', borderBox.offsetWidth, '(ПРЕДСКАЗУЕМО!)')
    
    // ===== Margin Collapse =====
    console.log('\n=== Margin Collapse ===')
    
    function verticalGap(mb, mt, isFlex = false) {
      // В flex/grid — отступы СКЛАДЫВАЮТСЯ (нет collapse)
      return isFlex ? mb + mt : Math.max(mb, mt)
    }
    
    const scenarios = [
      { mb: 20, mt: 20, flex: false, desc: 'div(mb:20) + div(mt:20) — обычный поток' },
      { mb: 20, mt: 30, flex: false, desc: 'div(mb:20) + div(mt:30) — обычный поток' },
      { mb: 20, mt: 30, flex: true,  desc: 'flex-item(mb:20) + flex-item(mt:30)' },
      { mb: 0,  mt: 40, flex: false, desc: 'div(mb:0) + div(mt:40)' },
    ]
    
    for (const s of scenarios) {
      const gap  = verticalGap(s.mb, s.mt, s.flex)
      const note = s.flex ? '(flex — нет collapse)' : '(collapse: max)'
      console.log(`${s.desc}`)
      console.log(`  ${s.mb}px + ${s.mt}px = ${gap}px ${note}`)
    }
    
    // ===== offsetWidth vs clientWidth vs scrollWidth =====
    console.log('\n=== offsetWidth / clientWidth / scrollWidth ===')
    
    function simulateScrollElement(containerW, contentW, padding, border) {
      // clientWidth: ширина без border, без скроллбара (15px стандарт)
      const SCROLLBAR = 15
      return {
        offsetWidth:  containerW,
        clientWidth:  containerW - 2 * border - SCROLLBAR,
        scrollWidth:  contentW + 2 * padding,
        canScroll:    (contentW + 2 * padding) > (containerW - 2 * border - SCROLLBAR),
      }
    }
    
    const scrollEl = simulateScrollElement(400, 800, 16, 1)
    console.log('Контейнер 400px, контент 800px:')
    console.log(`  offsetWidth  = ${scrollEl.offsetWidth}px  (с border)`)
    console.log(`  clientWidth  = ${scrollEl.clientWidth}px  (без border, без скроллбара)`)
    console.log(`  scrollWidth  = ${scrollEl.scrollWidth}px  (полный контент)`)
    console.log(`  Скроллируемый: ${scrollEl.canScroll}`)
    
    // ===== overflow text-overflow =====
    console.log('\n=== overflow паттерны ===')
    
    // Симулируем text-overflow: ellipsis логикой
    function truncateToWidth(text, maxChars, suffix = '…') {
      if (text.length <= maxChars) return text
      return text.slice(0, maxChars - suffix.length) + suffix
    }
    
    const longTitle = 'JavaScript — это высокоуровневый язык программирования'
    const widths = [20, 30, 40, 50]
    for (const w of widths) {
      console.log(`  ${w} chars: "${truncateToWidth(longTitle, w)}"`)
    }
    
    // ===== calc() паттерны =====
    console.log('\n=== calc() в реальных ситуациях ===')
    const VIEWPORT_HEIGHT = 900
    const HEADER_H = 64, FOOTER_H = 48, PADDING = 32
    
    const contentHeight = VIEWPORT_HEIGHT - HEADER_H - FOOTER_H - 2 * PADDING
    console.log(`calc(100vh - 64px - 48px - 64px) = ${contentHeight}px — высота контента`)
    console.log(`calc(50% - 8px) при 1200px = ${1200 / 2 - 8}px — колонка в 2-колоночном grid`)

    Box Model: box-sizing, margin, overflow

    Ты ставишь элементу width: 200px, добавляешь padding: 20px — и он вдруг стал 240px. Или два блока с margin-bottom: 20px и margin-top: 30px — ожидаешь 50px между ними, а получаешь 30px. Или читаешь element.offsetWidth в JavaScript и получаешь не то, что думаешь. Box Model объясняет всё это.

    Какую проблему решает

    Браузер вычисляет размеры элементов по Box Model. JavaScript-разработчик работает с этими размерами постоянно: позиционирование, анимации, вычисление доступного пространства. Непонимание box-sizing, margin collapse, и разницы offsetWidth/clientWidth приводит к багам в лейауте.

    На основе предыдущих уроков

  • CSS Units: px-значения в вычислениях box model
  • Flexbox/position: flex и grid отменяют margin collapse
  • DOM: element.offsetWidth, clientWidth, getBoundingClientRect
  • content-box vs border-box

    // content-box (исторический дефолт, ПЛОХОЙ):
    // width: 200px + padding: 20px + border: 2px
    // Итоговый offsetWidth = 200 + 40 + 4 = 244px  (НЕОЖИДАННО!)
    
    // border-box (современный стандарт, ХОРОШИЙ):
    // width: 200px включает padding И border
    // Итоговый offsetWidth = 200px  (предсказуемо!)
    // Контент = 200 - 40 - 4 = 156px
    
    // Сброс на border-box (ставь всегда в начале CSS):
    // *, *::before, *::after { box-sizing: border-box; }

    Margin collapse — схлопывание отступов

    // Вертикальные margin соседних блоков схлопываются:
    // .el1 { margin-bottom: 20px }
    // .el2 { margin-top: 30px }
    // Фактический отступ = max(20, 30) = 30px  (не 50!)
    
    // Схлопывание НЕ происходит:
    // - в flex/grid контейнерах
    // - при padding/border между родителем и первым/последним ребёнком
    // - для inline-block элементов
    // - горизонтальные margin НИКОГДА не схлопываются

    overflow — поведение содержимого

    // visible — выходит за границы (по умолчанию)
    // hidden  — обрезается
    // scroll  — всегда показывает скроллбар
    // auto    — скроллбар только при необходимости (рекомендуется)
    // clip    — как hidden, но отключает прокрутку через JS
    
    // overflow-x и overflow-y независимы:
    // overflow-x: hidden; overflow-y: auto;
    
    // Паттерны:
    // Прокручиваемый список:  max-height: 400px; overflow-y: auto;
    // Обрезка текста:         overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
    // Скрытие для анимации:   overflow: hidden; (+ transform)

    Размеры элементов в JavaScript

    // offsetWidth  = content + padding + border  (без margin)
    // clientWidth  = content + padding           (без border, без scrollbar)
    // scrollWidth  = полная ширина прокручиваемого контента
    
    // getBoundingClientRect() — точные размеры с дробями:
    // { width, height, top, left, right, bottom } — relative to viewport
    
    // Наглядно:
    // [margin | border | padding | content | padding | border | margin]
    //          ^-------- offsetWidth --------^
    //                   ^--- clientWidth ---^
    //                            ^content^
    
    // scrollWidth >= clientWidth (если есть горизонтальный скролл)

    CSS calc() для Box Model

    // Типичные паттерны:
    // height: calc(100vh - 64px)         — высота минус шапка
    // width: calc(100% - 2 * 16px)       — ширина минус отступы
    // width: calc(50% - 8px)             — в 2-колоночном grid с gap
    
    // Из JavaScript:
    element.style.width = `calc(100% - ${sidebarWidth}px)`

    Типичные ошибки

    Ошибка 1: Не использовать border-box

    /* ПЛОХО — неожиданные размеры */
    .input { width: 100%; padding: 12px 16px; }
    /* Ширина стала 100% + 32px горизонтальных padding! */
    
    /* ХОРОШО */
    *, *::before, *::after { box-sizing: border-box; }
    .input { width: 100%; padding: 12px 16px; }
    /* Ширина = ровно 100%, padding внутри */

    Ошибка 2: Удивляться margin collapse

    /* Ожидаешь 40px между параграфами, получаешь 20px */
    p { margin-top: 20px; margin-bottom: 20px; }
    /* Решение: */
    p + p { margin-top: 0; }
    /* или использовать padding вместо margin на контейнере */

    Ошибка 3: Путать offsetWidth и clientWidth

    // Для позиционирования (включая border):
    const fullWidth = element.offsetWidth
    
    // Для внутреннего контента (без border, без scrollbar):
    const innerWidth = element.clientWidth
    
    // Для проверки переполнения:
    const hasOverflow = element.scrollWidth > element.clientWidth

    В реальных проектах

  • Адаптивные инпуты: box-sizing: border-box + width: 100% — без сюрпризов
  • Прокручиваемые списки: max-height + overflow-y: auto для выпадающих меню
  • Виртуальный скролл: scrollHeight, scrollTop, clientHeight для расчёта видимых элементов
  • Resizable panels: offsetWidth для вычисления размеров при перетаскивании
  • Примеры

    Симуляция Box Model: вычисление размеров, margin collapse, overflow и scrollable контейнеры

    // Симуляция Box Model — чистые вычисления
    
    // ===== Вычисление размеров =====
    
    function calculateBoxModel({ width, height, padding, border, margin, scrollbarWidth = 0 }) {
      // --- content-box (исторический) ---
      const contentBox = {
        contentWidth:  width,
        contentHeight: height,
        offsetWidth:   width + 2 * padding + 2 * border,
        clientWidth:   width + 2 * padding - scrollbarWidth,
        totalWidth:    width + 2 * padding + 2 * border + 2 * margin,
      }
    
      // --- border-box (современный) ---
      const borderBox = {
        contentWidth:  width - 2 * padding - 2 * border,
        contentHeight: height - 2 * padding - 2 * border,
        offsetWidth:   width,                                    // width уже включает всё
        clientWidth:   width - 2 * border - scrollbarWidth,
        totalWidth:    width + 2 * margin,
      }
    
      return { contentBox, borderBox }
    }
    
    // === Демонстрация: content-box vs border-box ===
    console.log('=== content-box vs border-box (width=200, padding=20, border=2, margin=16) ===')
    const el = { width: 200, height: 100, padding: 20, border: 2, margin: 16 }
    const { contentBox, borderBox } = calculateBoxModel(el)
    
    console.log()
    console.log('Свойство'.padEnd(18) + 'content-box'.padEnd(14) + 'border-box')
    console.log('-'.repeat(44))
    const props = ['contentWidth', 'offsetWidth', 'clientWidth', 'totalWidth']
    for (const p of props) {
      console.log(p.padEnd(18) + String(contentBox[p]).padEnd(14) + String(borderBox[p]))
    }
    
    console.log()
    console.log('content-box: ожидаешь 200px, получаешь offsetWidth =', contentBox.offsetWidth, '(НЕОЖИДАННО!)')
    console.log('border-box:  width =', borderBox.offsetWidth, '(ПРЕДСКАЗУЕМО!)')
    
    // ===== Margin Collapse =====
    console.log('\n=== Margin Collapse ===')
    
    function verticalGap(mb, mt, isFlex = false) {
      // В flex/grid — отступы СКЛАДЫВАЮТСЯ (нет collapse)
      return isFlex ? mb + mt : Math.max(mb, mt)
    }
    
    const scenarios = [
      { mb: 20, mt: 20, flex: false, desc: 'div(mb:20) + div(mt:20) — обычный поток' },
      { mb: 20, mt: 30, flex: false, desc: 'div(mb:20) + div(mt:30) — обычный поток' },
      { mb: 20, mt: 30, flex: true,  desc: 'flex-item(mb:20) + flex-item(mt:30)' },
      { mb: 0,  mt: 40, flex: false, desc: 'div(mb:0) + div(mt:40)' },
    ]
    
    for (const s of scenarios) {
      const gap  = verticalGap(s.mb, s.mt, s.flex)
      const note = s.flex ? '(flex — нет collapse)' : '(collapse: max)'
      console.log(`${s.desc}`)
      console.log(`  ${s.mb}px + ${s.mt}px = ${gap}px ${note}`)
    }
    
    // ===== offsetWidth vs clientWidth vs scrollWidth =====
    console.log('\n=== offsetWidth / clientWidth / scrollWidth ===')
    
    function simulateScrollElement(containerW, contentW, padding, border) {
      // clientWidth: ширина без border, без скроллбара (15px стандарт)
      const SCROLLBAR = 15
      return {
        offsetWidth:  containerW,
        clientWidth:  containerW - 2 * border - SCROLLBAR,
        scrollWidth:  contentW + 2 * padding,
        canScroll:    (contentW + 2 * padding) > (containerW - 2 * border - SCROLLBAR),
      }
    }
    
    const scrollEl = simulateScrollElement(400, 800, 16, 1)
    console.log('Контейнер 400px, контент 800px:')
    console.log(`  offsetWidth  = ${scrollEl.offsetWidth}px  (с border)`)
    console.log(`  clientWidth  = ${scrollEl.clientWidth}px  (без border, без скроллбара)`)
    console.log(`  scrollWidth  = ${scrollEl.scrollWidth}px  (полный контент)`)
    console.log(`  Скроллируемый: ${scrollEl.canScroll}`)
    
    // ===== overflow text-overflow =====
    console.log('\n=== overflow паттерны ===')
    
    // Симулируем text-overflow: ellipsis логикой
    function truncateToWidth(text, maxChars, suffix = '…') {
      if (text.length <= maxChars) return text
      return text.slice(0, maxChars - suffix.length) + suffix
    }
    
    const longTitle = 'JavaScript — это высокоуровневый язык программирования'
    const widths = [20, 30, 40, 50]
    for (const w of widths) {
      console.log(`  ${w} chars: "${truncateToWidth(longTitle, w)}"`)
    }
    
    // ===== calc() паттерны =====
    console.log('\n=== calc() в реальных ситуациях ===')
    const VIEWPORT_HEIGHT = 900
    const HEADER_H = 64, FOOTER_H = 48, PADDING = 32
    
    const contentHeight = VIEWPORT_HEIGHT - HEADER_H - FOOTER_H - 2 * PADDING
    console.log(`calc(100vh - 64px - 48px - 64px) = ${contentHeight}px — высота контента`)
    console.log(`calc(50% - 8px) при 1200px = ${1200 / 2 - 8}px — колонка в 2-колоночном grid`)

    Задание

    Реализуй функцию `calculateBoxSizing(params)` которая вычисляет ключевые размеры в обоих режимах `box-sizing`. `params`: `{ width, padding, border, margin, scrollbarWidth = 0 }` Возвращает: `{ contentBox, borderBox }` — каждый с полями `contentWidth`, `offsetWidth`, `clientWidth`, `totalWidth`

    Подсказка

    content-box offsetWidth = width + 2*padding + 2*border. content-box clientWidth = width + 2*padding - scrollbarWidth. border-box contentWidth = width - 2*padding - 2*border. border-box clientWidth = width - 2*border - scrollbarWidth

    Загружаем среду выполнения...
    Загружаем AI-помощника...