← JavaScript/Типографика CSS#170 из 383← ПредыдущийСледующий →+15 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Типографика CSS

Ты разрабатываешь карточку товара: название может быть длинным и обрезается с многоточием. Время чтения статьи — нужно считать слова. Список новостей — заголовки разной длины должны занимать одинаковую высоту. Всё это — типографика в JavaScript: не просто CSS, но и работа со строками, size-вычисления, overflow-логика.

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

Типографика в интерфейсе влияет на читаемость, UX и доступность. JS-разработчик работает с текстом постоянно: усекает длинные строки, подсчитывает слова, управляет переносами. Неправильные настройки приводят к некрасивым переносам слов, неожиданным размерам и плохой читаемости.

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

  • Строки: .slice(), .split(), .trim() для работы с текстом
  • CSS Units: font-size в rem, line-height безразмерное
  • Box Model: scrollWidth > clientWidth для определения обрезанного текста
  • font-size и line-height

    // font-size: используй rem для масштабируемости
    // Оптимальный размер основного текста: 1rem (16px)
    
    // line-height: ИСПОЛЬЗУЙ безразмерное значение!
    // line-height: 1.5    — 1.5× от font-size (наследуется правильно)
    // line-height: 1.5em  — фиксируется при наследовании (ПЛОХО!)
    // line-height: 24px   — не масштабируется (ПЛОХО!)
    
    // Рекомендации:
    // Основной текст:   1.5 — 1.8
    // Заголовки:        1.1 — 1.3
    // Кнопки/labels:    1.0 — 1.2
    // UI компоненты:    1.4

    text-overflow: ellipsis

    Чтобы обрезать текст с многоточием, нужны все три свойства:

    .truncate {
      overflow: hidden;          /* обрезаем */
      white-space: nowrap;       /* запрещаем перенос */
      text-overflow: ellipsis;   /* добавляем "..." */
    }

    Из JavaScript:

    // Определить обрезан ли текст
    function isTextTruncated(element) {
      return element.scrollWidth > element.clientWidth
    }

    white-space — управление пробелами

    // normal    — схлопывает пробелы, перенос строки
    // nowrap    — схлопывает пробелы, БЕЗ переноса (одна строка)
    // pre       — сохраняет всё как есть (как в <pre>)
    // pre-wrap  — сохраняет, но переносит при необходимости
    // pre-line  — схлопывает пробелы, сохраняет переносы строк

    font-weight и font-family

    // Числовые значения font-weight:
    // 100 Thin | 200 ExtraLight | 300 Light | 400 Regular
    // 500 Medium | 600 SemiBold | 700 Bold | 800 ExtraBold | 900 Black
    
    // font-family: всегда указывай fallback!
    // 'Inter', 'Helvetica Neue', Arial, sans-serif
    // system-ui, -apple-system, sans-serif  — системный шрифт без загрузки
    // 'JetBrains Mono', Consolas, monospace  — для кода

    letter-spacing и word-spacing

    // letter-spacing: 0.05em   — для UPPERCASE кнопок
    // letter-spacing: -0.02em  — для крупных заголовков (сжатие)
    // word-spacing: 0.1em      — увеличить расстояние между словами
    
    // text-transform: uppercase | lowercase | capitalize
    // Кнопки: text-transform: uppercase + letter-spacing: 0.05em

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

    Ошибка 1: text-overflow без white-space: nowrap

    /* НЕВЕРНО — текст всё равно переносится */
    .title {
      overflow: hidden;
      text-overflow: ellipsis;
      /* Забыли white-space: nowrap! */
    }
    
    /* ВЕРНО — все три обязательны */
    .title {
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
    }

    Ошибка 2: line-height с единицами

    /* ПЛОХО — фиксируется при наследовании */
    .parent { font-size: 16px; line-height: 24px; }
    .child  { font-size: 24px; }
    /* child наследует line-height: 24px, но у него font-size: 24px — слишком тесно */
    
    /* ХОРОШО — безразмерное значение масштабируется */
    .parent { font-size: 16px; line-height: 1.5; }
    .child  { font-size: 24px; }
    /* child наследует 1.5 → line-height: 36px (1.5 × 24px) */

    Ошибка 3: Неправильный подсчёт слов

    // ПЛОХО — не работает с множественными пробелами
    'hello  world'.split(' ').length  // 3, а не 2!
    
    // ХОРОШО — \s+ соответствует любым пробелам
    'hello  world'.trim().split(/\s+/).length  // 2
    ''.trim().split(/\s+/).length  // 1 — ОШИБКА! Нужна проверка
    
    // ВЕРНО
    function countWords(text) {
      const trimmed = text.trim()
      return trimmed === '' ? 0 : trimmed.split(/\s+/).length
    }

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

  • Карточки товаров: text-overflow: ellipsis для названий разной длины
  • Время чтения: countWords / 200 wpm → "5 минут чтения" (как на Medium)
  • Динамическое усечение: truncateText для предпросмотра контента
  • Дизайн-системы: типографическая шкала (xs/sm/base/lg/xl/2xl) в rem
  • Моноширинный редактор: font-family: monospace + pre-wrap для кода
  • Примеры

    Работа с типографикой в JS: truncateText, countWords, readingTime, анализ шрифтовой шкалы

    // Типографика в JavaScript
    
    // ===== truncateText =====
    // Усекает по символам, старается не разрывать слово
    function truncateText(text, maxLength, suffix = '...') {
      if (text.length <= maxLength) return text
      const truncated = text.slice(0, maxLength - suffix.length)
      // Ищем последний пробел (не обрываем посередине слова)
      const lastSpace = truncated.lastIndexOf(' ')
      const result = lastSpace > maxLength * 0.6
        ? truncated.slice(0, lastSpace)
        : truncated
      return result + suffix
    }
    
    // Усекает по словам
    function truncateWords(text, maxWords, suffix = '...') {
      const words = text.trim().split(/\s+/)
      if (words.length <= maxWords) return text
      return words.slice(0, maxWords).join(' ') + suffix
    }
    
    console.log('=== truncateText ===')
    const article = 'JavaScript — это высокоуровневый язык программирования для создания веб-приложений'
    console.log(truncateText(article, 40))
    console.log(truncateText(article, 60))
    console.log(truncateText(article, 20, '…'))
    console.log(truncateText('Коротко', 50))  // без изменений
    
    console.log('\n=== truncateWords ===')
    console.log(truncateWords(article, 5))
    console.log(truncateWords(article, 10))
    console.log(truncateWords('Три слова здесь', 10))  // без изменений
    
    // ===== Подсчёт слов и символов =====
    function countWords(text) {
      const trimmed = text.trim()
      return trimmed === '' ? 0 : trimmed.split(/\s+/).length
    }
    
    function countChars(text, includeSpaces = true) {
      return includeSpaces ? text.length : text.replace(/\s/g, '').length
    }
    
    function readingTime(text, wpm = 200) {
      const words   = countWords(text)
      const minutes = Math.ceil(words / wpm)
      return { words, minutes, label: `${minutes} мин` }
    }
    
    console.log('\n=== Подсчёт слов ===')
    const samples = [
      'Одно',
      'Hello World',
      '  много   пробелов   вокруг  ',
      '',
      'a b c d e f',
    ]
    for (const s of samples) {
      console.log(`"${s.trim()}" → ${countWords(s)} слов, ${countChars(s)} симв, ${countChars(s, false)} без пробелов`)
    }
    
    console.log('\n=== Время чтения ===')
    const texts = [
      { label: 'Твит',    text: 'Привет мир! Это тест.' },
      { label: 'Заметка', text: Array(100).fill('слово').join(' ') },
      { label: 'Статья',  text: Array(500).fill('слово').join(' ') },
      { label: 'Книга',   text: Array(2000).fill('слово').join(' ') },
    ]
    for (const { label, text } of texts) {
      const rt = readingTime(text)
      console.log(`  ${label.padEnd(10)}: ${String(rt.words).padStart(5)} слов → ${rt.label} чтения`)
    }
    
    // ===== text-transform =====
    console.log('\n=== text-transform ===')
    function textTransform(text, mode) {
      switch (mode) {
        case 'uppercase':  return text.toUpperCase()
        case 'lowercase':  return text.toLowerCase()
        case 'capitalize': return text.replace(/(?:^|\s)\S/g, c => c.toUpperCase())
        default: return text
      }
    }
    
    const phrase = 'hello world from javascript'
    console.log('uppercase:  ', textTransform(phrase, 'uppercase'))
    console.log('lowercase:  ', textTransform('HELLO WORLD', 'lowercase'))
    console.log('capitalize: ', textTransform(phrase, 'capitalize'))
    
    // ===== Типографическая шкала =====
    console.log('\n=== Типографическая шкала (base 16px) ===')
    const scale = [
      { name: 'xs',   rem: 0.75  },
      { name: 'sm',   rem: 0.875 },
      { name: 'base', rem: 1     },
      { name: 'lg',   rem: 1.125 },
      { name: 'xl',   rem: 1.25  },
      { name: '2xl',  rem: 1.5   },
      { name: '3xl',  rem: 1.875 },
      { name: '4xl',  rem: 2.25  },
    ]
    const ROOT_PX = 16
    for (const { name, rem } of scale) {
      const px = rem * ROOT_PX
      const lh = px >= 24 ? 1.2 : 1.6  // заголовки — 1.2, текст — 1.6
      console.log(
        `  text-${name.padEnd(4)}: ${String(rem).padEnd(6)}rem = ${String(px).padEnd(5)}px  line-height: ${lh}`
      )
    }

    Типографика CSS

    Ты разрабатываешь карточку товара: название может быть длинным и обрезается с многоточием. Время чтения статьи — нужно считать слова. Список новостей — заголовки разной длины должны занимать одинаковую высоту. Всё это — типографика в JavaScript: не просто CSS, но и работа со строками, size-вычисления, overflow-логика.

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

    Типографика в интерфейсе влияет на читаемость, UX и доступность. JS-разработчик работает с текстом постоянно: усекает длинные строки, подсчитывает слова, управляет переносами. Неправильные настройки приводят к некрасивым переносам слов, неожиданным размерам и плохой читаемости.

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

  • Строки: .slice(), .split(), .trim() для работы с текстом
  • CSS Units: font-size в rem, line-height безразмерное
  • Box Model: scrollWidth > clientWidth для определения обрезанного текста
  • font-size и line-height

    // font-size: используй rem для масштабируемости
    // Оптимальный размер основного текста: 1rem (16px)
    
    // line-height: ИСПОЛЬЗУЙ безразмерное значение!
    // line-height: 1.5    — 1.5× от font-size (наследуется правильно)
    // line-height: 1.5em  — фиксируется при наследовании (ПЛОХО!)
    // line-height: 24px   — не масштабируется (ПЛОХО!)
    
    // Рекомендации:
    // Основной текст:   1.5 — 1.8
    // Заголовки:        1.1 — 1.3
    // Кнопки/labels:    1.0 — 1.2
    // UI компоненты:    1.4

    text-overflow: ellipsis

    Чтобы обрезать текст с многоточием, нужны все три свойства:

    .truncate {
      overflow: hidden;          /* обрезаем */
      white-space: nowrap;       /* запрещаем перенос */
      text-overflow: ellipsis;   /* добавляем "..." */
    }

    Из JavaScript:

    // Определить обрезан ли текст
    function isTextTruncated(element) {
      return element.scrollWidth > element.clientWidth
    }

    white-space — управление пробелами

    // normal    — схлопывает пробелы, перенос строки
    // nowrap    — схлопывает пробелы, БЕЗ переноса (одна строка)
    // pre       — сохраняет всё как есть (как в <pre>)
    // pre-wrap  — сохраняет, но переносит при необходимости
    // pre-line  — схлопывает пробелы, сохраняет переносы строк

    font-weight и font-family

    // Числовые значения font-weight:
    // 100 Thin | 200 ExtraLight | 300 Light | 400 Regular
    // 500 Medium | 600 SemiBold | 700 Bold | 800 ExtraBold | 900 Black
    
    // font-family: всегда указывай fallback!
    // 'Inter', 'Helvetica Neue', Arial, sans-serif
    // system-ui, -apple-system, sans-serif  — системный шрифт без загрузки
    // 'JetBrains Mono', Consolas, monospace  — для кода

    letter-spacing и word-spacing

    // letter-spacing: 0.05em   — для UPPERCASE кнопок
    // letter-spacing: -0.02em  — для крупных заголовков (сжатие)
    // word-spacing: 0.1em      — увеличить расстояние между словами
    
    // text-transform: uppercase | lowercase | capitalize
    // Кнопки: text-transform: uppercase + letter-spacing: 0.05em

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

    Ошибка 1: text-overflow без white-space: nowrap

    /* НЕВЕРНО — текст всё равно переносится */
    .title {
      overflow: hidden;
      text-overflow: ellipsis;
      /* Забыли white-space: nowrap! */
    }
    
    /* ВЕРНО — все три обязательны */
    .title {
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
    }

    Ошибка 2: line-height с единицами

    /* ПЛОХО — фиксируется при наследовании */
    .parent { font-size: 16px; line-height: 24px; }
    .child  { font-size: 24px; }
    /* child наследует line-height: 24px, но у него font-size: 24px — слишком тесно */
    
    /* ХОРОШО — безразмерное значение масштабируется */
    .parent { font-size: 16px; line-height: 1.5; }
    .child  { font-size: 24px; }
    /* child наследует 1.5 → line-height: 36px (1.5 × 24px) */

    Ошибка 3: Неправильный подсчёт слов

    // ПЛОХО — не работает с множественными пробелами
    'hello  world'.split(' ').length  // 3, а не 2!
    
    // ХОРОШО — \s+ соответствует любым пробелам
    'hello  world'.trim().split(/\s+/).length  // 2
    ''.trim().split(/\s+/).length  // 1 — ОШИБКА! Нужна проверка
    
    // ВЕРНО
    function countWords(text) {
      const trimmed = text.trim()
      return trimmed === '' ? 0 : trimmed.split(/\s+/).length
    }

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

  • Карточки товаров: text-overflow: ellipsis для названий разной длины
  • Время чтения: countWords / 200 wpm → "5 минут чтения" (как на Medium)
  • Динамическое усечение: truncateText для предпросмотра контента
  • Дизайн-системы: типографическая шкала (xs/sm/base/lg/xl/2xl) в rem
  • Моноширинный редактор: font-family: monospace + pre-wrap для кода
  • Примеры

    Работа с типографикой в JS: truncateText, countWords, readingTime, анализ шрифтовой шкалы

    // Типографика в JavaScript
    
    // ===== truncateText =====
    // Усекает по символам, старается не разрывать слово
    function truncateText(text, maxLength, suffix = '...') {
      if (text.length <= maxLength) return text
      const truncated = text.slice(0, maxLength - suffix.length)
      // Ищем последний пробел (не обрываем посередине слова)
      const lastSpace = truncated.lastIndexOf(' ')
      const result = lastSpace > maxLength * 0.6
        ? truncated.slice(0, lastSpace)
        : truncated
      return result + suffix
    }
    
    // Усекает по словам
    function truncateWords(text, maxWords, suffix = '...') {
      const words = text.trim().split(/\s+/)
      if (words.length <= maxWords) return text
      return words.slice(0, maxWords).join(' ') + suffix
    }
    
    console.log('=== truncateText ===')
    const article = 'JavaScript — это высокоуровневый язык программирования для создания веб-приложений'
    console.log(truncateText(article, 40))
    console.log(truncateText(article, 60))
    console.log(truncateText(article, 20, '…'))
    console.log(truncateText('Коротко', 50))  // без изменений
    
    console.log('\n=== truncateWords ===')
    console.log(truncateWords(article, 5))
    console.log(truncateWords(article, 10))
    console.log(truncateWords('Три слова здесь', 10))  // без изменений
    
    // ===== Подсчёт слов и символов =====
    function countWords(text) {
      const trimmed = text.trim()
      return trimmed === '' ? 0 : trimmed.split(/\s+/).length
    }
    
    function countChars(text, includeSpaces = true) {
      return includeSpaces ? text.length : text.replace(/\s/g, '').length
    }
    
    function readingTime(text, wpm = 200) {
      const words   = countWords(text)
      const minutes = Math.ceil(words / wpm)
      return { words, minutes, label: `${minutes} мин` }
    }
    
    console.log('\n=== Подсчёт слов ===')
    const samples = [
      'Одно',
      'Hello World',
      '  много   пробелов   вокруг  ',
      '',
      'a b c d e f',
    ]
    for (const s of samples) {
      console.log(`"${s.trim()}" → ${countWords(s)} слов, ${countChars(s)} симв, ${countChars(s, false)} без пробелов`)
    }
    
    console.log('\n=== Время чтения ===')
    const texts = [
      { label: 'Твит',    text: 'Привет мир! Это тест.' },
      { label: 'Заметка', text: Array(100).fill('слово').join(' ') },
      { label: 'Статья',  text: Array(500).fill('слово').join(' ') },
      { label: 'Книга',   text: Array(2000).fill('слово').join(' ') },
    ]
    for (const { label, text } of texts) {
      const rt = readingTime(text)
      console.log(`  ${label.padEnd(10)}: ${String(rt.words).padStart(5)} слов → ${rt.label} чтения`)
    }
    
    // ===== text-transform =====
    console.log('\n=== text-transform ===')
    function textTransform(text, mode) {
      switch (mode) {
        case 'uppercase':  return text.toUpperCase()
        case 'lowercase':  return text.toLowerCase()
        case 'capitalize': return text.replace(/(?:^|\s)\S/g, c => c.toUpperCase())
        default: return text
      }
    }
    
    const phrase = 'hello world from javascript'
    console.log('uppercase:  ', textTransform(phrase, 'uppercase'))
    console.log('lowercase:  ', textTransform('HELLO WORLD', 'lowercase'))
    console.log('capitalize: ', textTransform(phrase, 'capitalize'))
    
    // ===== Типографическая шкала =====
    console.log('\n=== Типографическая шкала (base 16px) ===')
    const scale = [
      { name: 'xs',   rem: 0.75  },
      { name: 'sm',   rem: 0.875 },
      { name: 'base', rem: 1     },
      { name: 'lg',   rem: 1.125 },
      { name: 'xl',   rem: 1.25  },
      { name: '2xl',  rem: 1.5   },
      { name: '3xl',  rem: 1.875 },
      { name: '4xl',  rem: 2.25  },
    ]
    const ROOT_PX = 16
    for (const { name, rem } of scale) {
      const px = rem * ROOT_PX
      const lh = px >= 24 ? 1.2 : 1.6  // заголовки — 1.2, текст — 1.6
      console.log(
        `  text-${name.padEnd(4)}: ${String(rem).padEnd(6)}rem = ${String(px).padEnd(5)}px  line-height: ${lh}`
      )
    }

    Задание

    Реализуй набор типографических утилит для интерфейса. Реализуй: - `truncateText(text, maxLength, suffix)` — усекает текст до `maxLength` символов и добавляет suffix - `countWords(text)` — подсчитывает слова (корректно обрабатывает множественные пробелы и пустую строку) - `readingTime(text, wpm)` — возвращает `{ words, minutes }` — время чтения при `wpm` слов/мин (по умолчанию 200)

    Подсказка

    truncateText: text.slice(0, maxLength - suffix.length) + suffix. countWords: trim() === "" ? 0 : trim().split(/\s+/).length. readingTime: words = countWords(text), minutes = Math.ceil(words / wpm)

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