Border Radius

Каскад скруглений: дочерние блоки наследуют уменьшенный радиус автоматически. Готовые классы от 0 до 4rem + pill. Зазор --gr-p задаётся переменной, поэтому первый уровень работает без JS. Произвольную глубину достраивает опциональный griffincss-utils.js — без единого замера в рантайме.

Опциональный модуль scss/modules/_border-radius.scss CSS + опциональный JS

Концепция

padding контейнера = --gr-p
child_radius     = max(0, --gr-r − --gr-p)

Зазор один на все стороны, и обе переменные ненаследуемые: значение уровня объявляется на нём самом, а вниз уходит отдельная публикация — --gr-r-down и --gr-r-gap.

ПеременнаяНазначениеПо умолчанию
--gr-r border-radius контейнера 0px
--gr-p Зазор между контейнером и детьми; из него выводится padding 0px

Зазор — источник, padding — следствие: .gr-radius выводит свой padding из --gr-p, поэтому значение известно CSS и замеры в рантайме не нужны. Нужен padding, не равный зазору, — задайте его отдельно: собственное свойство перекроет вывод из переменной.

Готовые классы

Вместо ручного style="--gr-r: 16px" используйте классы — чище, короче, читаемее. Шаг 0.25rem, от .gr-radius-0 до .gr-radius-16 (0–4rem).

КлассЗначениеПри px = 16
.gr-radius-000px
.gr-radius-10.25rem4px
.gr-radius-20.5rem8px
.gr-radius-41rem16px
.gr-radius-61.5rem24px
.gr-radius-82rem32px
.gr-radius-123rem48px
.gr-radius-164rem64px

Каждый класс устанавливает и --gr-r, и border-radius — каскад на детей работает автоматически.

<!-- Было (ручные переменные) -->
<div class="gr-radius" style="--gr-r: 32px; --gr-p: 16px;">

<!-- Стало (готовый класс) -->
<div class="gr-radius gr-radius-8" style="--gr-p: 16px;">

Базовое использование

.gr-radius-8 = 32px (2rem). --gr-p: 16px даёт и зазор, и padding: 16px. Ребёнок: 32 − 16 = 16px.

<div class="gr-radius gr-radius-8" style="--gr-p: 16px;">
  <div>Мой радиус = 32 − 16 = 16px</div>
</div>
Результат
Ребёнок внутри контейнера
Радиус: 32−16 = 16px. Зазор из --gr-p.

--gr-r задан классом .gr-radius-8, зазор и padding — переменной --gr-p.

Многоуровневый каскад

Каждый уровень — новый .gr-radius. Внутренние дети без .gr-radius получают радиус по формуле.

Вложенному контейнеру радиус нужен свой. --gr-r зарегистрирована через @property как ненаследуемая, поэтому вложенный .gr-radius без собственного значения даёт квадрат, а не радиус предка. Это осознанное поведение: наследуйся переменная, вложенный контейнер тихо копировал бы радиус родителя один в один — картинка правдоподобная и неверная. Многоуровневый каскад без ручных значений достраивает рантайм griffincss-utils.js.

Осевые и односторонние отступы каскаду не годятся. .gr-px-*, .gr-py-*, .gr-pt-* и прочие односторонние --gr-p не объявляют: у асимметричного отступа нет одного значения, от которого считать радиус ребёнка. Для каскада используйте равномерный .gr-p-* или --gr-p напрямую. По той же причине --gr-p принимает одну длину: --gr-p: 8px 20px недействительно — нужен неравномерный padding, задайте его свойством.

<div class="gr-radius gr-radius-12" style="--gr-p: 16px;">
  <div class="gr-radius gr-radius-8" style="--gr-p: 8px;">
    <div class="gr-radius gr-radius-6" style="--gr-p: 6px;">
      <div>радиус = 24 − 6 = 18px</div>
    </div>
  </div>
</div>
Результат: 4 уровня
Глубина 3
Радиус: 24−6 = 18px (авто)

Уровень 1 (фиол.): 48px, зазор 16 → 2 (сирен.): 48−16 = 32px, зазор 8 → 3 (светл.): 32−8 = 24px, зазор 6 → внутр. блок: 24−6 = 18px. Каждая ступень выбрана по формуле: класс уровня совпадает с тем, что даёт max(0, --gr-r − --gr-p) предыдущего.

Произвольная глубина: рантайм

Ручные значения на каждом уровне — обязанность, пока работает один статический CSS: рекуррента r(n) = max(0, r(n−1) − p(n−1)) в нём невыразима. Подключите griffincss-utils.js — и явный радиус нужен только верхнему контейнеру, остальные рантайм выведет сам.

Зазор он читает и из класса, и из style — источники равноправны. Классы .gr-p-* берутся из той же таблицы значений, из которой собран CSS, поэтому .gr-p-3 и --gr-p: 12px для каскада — одно и то же. Шкала отступов идёт ступенями по 0.25rem, то есть кратно 4px; всё, что между ступенями, задаётся переменной. В примере ниже нечётные уровни — классы, чётные — произвольные значения.

<script src="griffincss.js"></script>
<script src="griffincss-utils.js"></script>

<div class="gr-radius gr-radius-16 gr-p-3">             <!-- 64px, зазор классом: 0.75rem -->
  <div class="gr-radius" style="--gr-p: 10px;">         <!-- 64−12 = 52px, зазор свой: 10px -->
    <div class="gr-radius gr-p-2">                      <!-- 52−10 = 42px, классом: 0.5rem -->
      <div class="gr-radius" style="--gr-p: 6px;">      <!-- 42−8  = 34px, своё: 6px -->
        <div class="gr-radius gr-p-1">                  <!-- 34−6  = 28px, классом: 0.25rem -->
          <div>радиус = 28 − 4 = 24px</div>
        </div>
      </div>
    </div>
  </div>
</div>
Результат: 5 уровней, радиус задан один раз
Глубина 5
Радиус: 28−4 = 24px (авто)

64px → 52 → 42 → 34 → 28 → 24. Явный радиус — только у верхнего контейнера, остальные вывел рантайм. Зазоры: .gr-p-3--gr-p: 10px.gr-p-2--gr-p: 6px.gr-p-1.

Рантайм не считает — он складывает выражение. Второму уровню примера достаётся --gr-r: max(0px, calc(4rem - 0.75rem)), третьему — max(0px, calc(max(0px, calc(4rem - 0.75rem)) - 10px)): rem из класса и px из style едут в одном выражении, и разрешает их CSS в calc(). Ни getComputedStyle, ни forced reflow — раскладку рантайм не трогает вовсе. Одинаковая форма вложенности делит один класс и одно правило.

Процентный радиус каскаду не годится. --gr-r: 50% формально проходит: и статическая формула, и рантайм соберут max(0px, calc(50% − 8px)), и браузер это применит. Но проценты в border-radius считаются от коробки самого элемента, а не предка, поэтому у ребёнка 50% — это половина уже другой коробки, и концентричности углов не получается. Для каскада задавайте радиус длиной: классом .gr-radius-* или px/rem.

Поток и живое дерево. Пока страница разбирается, контейнер получает радиус в момент появления, а не разом на DOMContentLoaded — как это делает парсер раскладок. После загрузки рантайм наблюдает за <body>: вставленное поддерево обрабатывается само, с дебаунсом 16 мс, а перенос поддерева к другому предку снимает прежний выведенный класс и пересчитывает радиус. Ручное управление — Griffincss.utils.radiusCascade(node), Griffincss.utils.observe(node), Griffincss.utils.stop().

Выключатели на теге <script>: data-stream="false" — без потокового режима, data-observe="false" — без наблюдения за живым деревом, data-auto="false" — не запускаться вовсе. Поток видно на странице потоковой раскладки.

Класс и style на одном контейнере — только согласованные. Если задать оба и с разными значениями, победит инлайн, а в консоли появится предупреждение Griffincss: --gr-p задан и классом (1rem), и в style (16px) — значения расходятся. Осевые и односторонние классы (.gr-px-*, .gr-py-*, .gr-pt-*) зазором не считаются вовсе — рантайм их не видит.

Без скрипта работают первый уровень и его прямые дети. С data-auto="false" на теге <script> (или без самого скрипта) вложенные контейнеры без явного --gr-r становятся квадратными — внешний контейнер и его непосредственные дети остаются скруглёнными в любом случае.

Pill / полностью скруглённый

.gr-radius-full задаёт border-radius: 9999px — бесконечное скругление. Используется для кнопок, бейджей, аватаров и любых pill-элементов.

<span class="gr-radius gr-radius-full" style="padding: 8px 20px;">Бейдж</span>
<button class="gr-radius gr-radius-full" style="padding: 12px 32px;">Кнопка</button>
Результат
Бейдж Pill-элемент

Адаптивный радиус

Изменение в 0.7.0. Классов вида .gr-radius-4-md больше нет. Полная сетка суффиксов стоила 68 классов при одном реальном сценарии на проект, а сам сценарий решается одной переменной.

Радиус берётся из --gr-r, поэтому меняется он обычным медиа-запросом — каскад по вложенности при этом продолжает работать: дочерние блоки пересчитают свой радиус сами.

/* острые углы на мобильном, 16px от 768px */
.card { --gr-r: 0; }

@media (width >= 768px) {
  .card { --gr-r: 1rem; }
}
<div class="gr-radius card" style="--gr-p: 16px;">
  Адаптивный радиус
</div>

Несколько детей в контейнере

Каскад действует на всех прямых потомков .gr-radius (кроме тех, что сами .gr-radius).

<div class="gr-radius gr-radius-8" style="--gr-p: 16px;">
  <header>Шапка</header>
  <div>Контент</div>
  <footer>Подвал</footer>
</div>
Результат
Шапка — авто 16px
Контент — авто 16px
Подвал — авто 16px

Три ребёнка. Радиус каждого: 32−16 = 16px. Одна настройка на всех.

Обнуление радиуса

Когда --gr-p ≥ --gr-r, дочерний радиус обнуляется: max(0, r − p).

<div class="gr-radius gr-radius-4" style="--gr-p: 8px;">
  <div>16−8 = 8px (скруглён)</div>
</div>

<div class="gr-radius gr-radius-4" style="--gr-p: 16px;">
  <div>max(0, 16−16) = 0px (острые углы)</div>
</div>

<div class="gr-radius gr-radius-4" style="--gr-p: 24px;">
  <div>max(0, 16−24) = 0px (острые углы)</div>
</div>
Результат

p=8 < r=16

8px
скруглён

p=16 = r=16

0px
острые

p=24 > r=16

0px
острые

Собственный радиус у ребёнка

border-radius напрямую на дочернем элементе перекрывает автоматический каскад.

<div class="gr-radius gr-radius-8" style="--gr-p: 12px;">
  <div>Авто-радиус 20px</div>
  <div style="border-radius: 6px;">Свой радиус 6px</div>
</div>
Результат
Авто-радиус 20px
Свой радиус 6px (переопределён)

Как работает

SCSS-модуль генерирует каскадную базу и полный набор утилитарных классов:

// Регистрация: обе переменные ненаследуемые
@property --gr-r { syntax: "<length-percentage>"; inherits: false; initial-value: 0px; }
@property --gr-p { syntax: "<length-percentage>"; inherits: false; initial-value: 0px; }

// Каскад
.gr-radius {
  padding: var(--gr-p);
  border-radius: var(--gr-r);

  // публикация вниз: дети читают её, а не --gr-r/--gr-p
  --gr-r-down: var(--gr-r);
  --gr-r-gap: var(--gr-p);
}
.gr-radius > :not(.gr-radius) {
  border-radius: max(0px, calc(var(--gr-r-down, 0px) - var(--gr-r-gap, 0px)));
}

// Готовые классы (0.25rem шаг, 0–4rem)
.gr-radius-0  { --gr-r: 0;      border-radius: 0; }
.gr-radius-1  { --gr-r: 0.25rem; border-radius: 0.25rem; }
...
.gr-radius-16 { --gr-r: 4rem;    border-radius: 4rem; }

// Pill
.gr-radius-full { --gr-r: 9999px; border-radius: 9999px; }

// Адаптивные: .gr-radius-N-{sm,md,lg,xl}

JS не участвует. До v0.7.0 рантайм обходил все .gr-radius, читал min(padding-top, padding-left) из computed-стилей и выставлял --gr-p инлайном — это давало forced reflow и устаревало при изменении ширины. Теперь модель перевёрнута: --gr-p задаёте вы, padding выводится из него.

Советы

Используйте готовые классы. .gr-radius-8 вместо style="--gr-r: 32px" — чище, консистентно с остальной библиотекой и работает с адаптивными вариантами.

Не злоупотребляйте глубиной. После 2–3 уровней вложенности разница в радиусе незаметна. Для глубокой вложенности — border-radius: 0 на внутренних элементах.

Примеры использования

Скругление задаёт характер интерфейса, а каскад избавляет от арифметики вложенных радиусов. Рецепты ниже — три самых частых применения: карточка с медиа, капсульные элементы и склеенные группы, где радиус живёт только на внешних углах.

Карточка с каскадом

Классика: карточка с изображением, текстом и кнопкой. Один раз задаётся радиус и внутреннее поле (.gr-radius-5 + --gr-p: 16px) — картинка и кнопка получают согласованный внутренний радиус сами, без вычислений «20 минус 16».

<div class="gr-radius gr-radius-5" style="--gr-p: 16px;">
  <img src="photo.jpg" alt="">
  <h3>Заголовок</h3>
  <p>Описание карточки.</p>
  <button>Подробнее</button>
</div>
Дети скруглены каскадом: 20−16 = 4px
Изображение — авто 4px

Заголовок карточки

Все дети получают радиус 20−16 = 4px автоматически.

Кнопка — авто 4px

Сегмент-контрол: капсула в капсуле

Переключатель периодов или вкладок-фильтров — две вложенные капсулы: серая подложка .gr-radius-full .gr-bg-sunken .gr-p-1 и активный сегмент — тоже .gr-radius-full, но на поверхности и с тенью .gr-shadow-sm. Ряд сегментов держит флекс с .gr-gap-sm; капсуле-подложке не нужен каскад — у полного скругления внутренний радиус всё равно «до упора».

<div class="gr-flex gr-w-fit gr-gap-sm gr-radius-full gr-bg-sunken gr-p-1">
  <button class="gr-btn gr-btn-sm gr-radius-full gr-bg-surface gr-shadow-sm">Месяц</button>
  <button class="gr-btn gr-btn-sm gr-btn-ghost gr-radius-full">Год</button>
</div>
Активный сегмент — капсула на подложке-капсуле

Склеенные группы: радиус только снаружи

Поле с кнопкой «Найти» или ряд слитых кнопок — задача «скругление на внешних углах, между элементами — общий бордер». Руками это возня с :first-child/:last-child; в ui она уже проделана — .gr-input-group и .gr-btn-group склеивают содержимое сами. Радиус группы при этом остаётся на токене, так что капсульный вариант — просто .gr-radius-full сверху.

<div class="gr-input-group">
  <input class="gr-input" type="search" placeholder="Поиск" aria-label="Поиск">
  <button class="gr-btn gr-btn-primary">Найти</button>
</div>
Группа скруглена как единое целое