Skip to content

CSS API extension #41

Description

@kseniya57

CSS API extension

Objective

Gravity UI already exposes global semantic tokens and CSS custom properties for many
visual aspects of its components. This RFC proposes an additive extension of that public
CSS API for component parts and states that cannot currently be customized at the correct
scope.

A public theming API should let theme authors customize supported semantic parts by
declaring custom properties on public theme scopes. Consumer styles must not depend on
private .g-* elements, modifiers, pseudo-elements, DOM structure, or CSS implementation
details.

This proposal is deliberately non-breaking:

  • it does not change the default Gravity UI theme;
  • it does not change component DOM, public props, behavior, sizes, or state precedence;
  • every new variable is optional;
  • when no new variable is defined, the rendered and computed result must be the same as
    before the change;
  • implementation details may be adjusted only to consume the new API without changing
    their default result.

Scope

The proposal covers public typography and components exported by @gravity-ui/uikit and
@gravity-ui/date-components: Text, Hotkey, Popup, legacy List, Menu, DropdownMenu,
SegmentedRadioGroup, Select, Switch, Calendar, Modal, Dialog, and Progress.

Breadcrumbs is included because it composes Popup, but it does not need a new variable. Its
menu popup must consume the existing Popup API.

Goals

  • Let custom themes configure the covered semantic parts without private Gravity UI
    selectors.
  • Expose only stable, semantic customization points that represent real theme differences.
  • Reuse existing global tokens and component APIs instead of publishing aliases.
  • Keep structural layout, state composition, accessibility, and rendering strategy owned
    by Gravity UI.
  • Preserve current size-specific, mode-specific, and state-specific Gravity UI defaults.
  • Support logical properties and RTL without publishing physical-direction APIs.
  • Allow the work to land component by component without requiring a major release.

Non-goals

  • Changing the appearance or behavior of an unthemed Gravity UI component.
  • Making private selectors, DOM nodes, pseudo-elements, or stacking strategy public.
  • Exposing every CSS declaration as a variable.
  • Adding a variable for a value already controlled by a suitable public semantic token.
  • Adding component aliases for variables owned by a nested public component.

API selection rules

A new variable belongs in this RFC only when all of the following are true:

  1. The property needs to be customizable independently at component scope.
  2. No existing public semantic token or component variable controls the value at the
    required scope.
  3. The value describes a stable visual part or state, rather than current markup or a CSS
    technique.
  4. Gravity UI can consume the variable while preserving its current result when the
    variable is absent.

There are two narrow additions for API completeness: an opposite logical side may be
included when it is already independently meaningful in the component, and all members of
an accepted typography family may be included together. These additions cover the mirror
variables and the six Text letter-spacing groups below; they do not justify a general
Cartesian product of properties and states.

The following do not become new CSS API:

  • declarations that repeat a semantic token already consumed by Gravity UI;
  • defensive resets or implementation-only declarations;
  • variables that only rename an existing public customization point;
  • aliases for Popup, Menu, List, Text, or Button variables used by compound components;
  • implementation choices such as whether a surface is a border, pseudo-element, or child.

Logical and mirror variables

Opposite logical sides are both exposed when they are independently meaningful in the
existing component contract. This is why Menu group-divider padding and margin have both
block-start and block-end variables, and Select group-label padding has both inline-start
and inline-end variables.

Mirrors are not added mechanically. A shorthand already controls both sides for Menu item
padding and DropdownMenu separator margin. Select list margin is intentionally block-axis
only because this proposal does not introduce independent inline-axis spacing.

Compatibility contract

The CSS API is additive, but adding a declaration can still cause a visual regression if
its fallback does not reproduce the existing cascade. Implementations must therefore meet
all of these requirements.

Preserve the current default result

For a direct value, use the current value as the fallback:

.g-component {
  property: var(--g-component-property, <current Gravity UI value>);
}

For size-, mode-, or state-dependent values, keep a private fallback per existing branch:

.g-component_size_s {
  --_--item-padding: <current s value>;
}

.g-component_size_m {
  --_--item-padding: <current m value>;
}

.g-component__item {
  padding: var(--g-component-item-padding, var(--_--item-padding));
}

A refactor is acceptable only if the computed default is equivalent for every supported
size, mode, state combination, LTR/RTL direction, and relevant input method.

Preserve the current cascade and state precedence

Gravity UI continues to decide which state wins. A public variable supplies a value; it
does not make consumer styles reproduce selectors.

  • Disabled remains stronger than hover, focus, pressed, checked, selected, active, and
    danger.
  • Selected, active, danger, and ordinary interactive items retain their current relative
    precedence unless a variable explicitly describes that exact state.
  • A component-owned foreground icon receives the corresponding semantic text color when
    a color variable is set, without changing its default color when variables are absent.
  • prefers-reduced-motion continues to override configurable motion.

Preserve public structure and behavior

The implementation must not require:

  • a new DOM node or a changed DOM order;
  • a new public prop;
  • a renamed or newly documented private class;
  • different focus, pointer, keyboard, disabled, or loading behavior.

Internal selectors may of course be used inside Gravity UI to attach the public variables
to the right semantic parts. The prohibition on .g-* selectors applies to consumer
theme CSS, not to the library's own styles.

If an internal cleanup cannot preserve the default result, it must not be bundled into
this RFC. The implementation should either keep the existing internal rule or propose an
additional semantic CSS variable. It must not silently change Gravity UI defaults to make
customization simpler.

Solution proposal

Add 72 new variables and make legacy List consume one existing variable.

Component or area New variables Existing API reused or integrated
Text 6 Existing typography family, weight, size, height
Hotkey 5 Body typography tokens remain the fallbacks
Popup 1 Existing background, border, and radius API
Legacy List 2 --g-list-item-border-radius
Menu 12 Existing semantic color tokens
DropdownMenu 2 Menu and Popup API
SegmentedRadioGroup 21 Existing size geometry
Select 8 List and Popup API
Switch 6 Existing semantic colors and size geometry
Calendar 1 Popup API for calendar popups
Modal 1 Existing Modal radius and sizing API
Dialog 6 Existing Text and Button implementation
Progress 1 Existing Progress color API
Breadcrumbs 0 Existing Popup API
Total 72

Proposed CSS variables

The “current Gravity UI result” column is normative for compatibility, not a request to
replace the existing declarations with hard-coded values. The implementation may preserve
the existing cascade or use private fallbacks.

Text — 6

Variable Applied to Current Gravity UI result when unset
--g-text-caption-letter-spacing All Text caption-* variants normal
--g-text-body-letter-spacing All Text body-* and body-short variants normal
--g-text-code-letter-spacing All Text code-* and code-inline-* variants normal
--g-text-subheader-letter-spacing All Text subheader-* variants normal
--g-text-header-letter-spacing All Text header-* variants normal
--g-text-display-letter-spacing All Text display-* variants normal

All six groups are included so typography stays coherent and Themer can represent the
same property for every supported group.

Hotkey — 5

Variable Applied to Current Gravity UI result when unset
--g-hotkey-font-family Hotkey content, including nested kbd elements --g-text-body-font-family
--g-hotkey-font-weight Hotkey content, including nested kbd elements --g-text-body-font-weight
--g-hotkey-font-size Hotkey content, including nested kbd elements --g-text-body-1-font-size
--g-hotkey-line-height Hotkey content, including nested kbd elements --g-text-body-1-line-height
--g-hotkey-letter-spacing Hotkey content, including nested kbd elements normal

The five variables form a complete Hotkey typography contract while preserving body
typography as the default.

Popup — 1

Variable Applied to Current Gravity UI result when unset
--g-popup-box-shadow Complete Popup shadow Existing two-layer Popup shadow

Popup already exposes --g-popup-background-color, --g-popup-border-color,
--g-popup-border-radius, and --g-popup-border-width. The shadow fallback must continue
to react to the resolved current border width and color exactly as it does today.

Legacy List — 2 new variables and one existing-variable integration

Variable Applied to Current Gravity UI result when unset
--g-list-padding Padding of the items container 0
--g-list-item-gap Block-axis space between adjacent non-virtualized items 0

Legacy List must also consume the existing --g-list-item-border-radius variable on its
item surface. This is an integration requirement, not a new variable.

--g-list-item-gap applies only to the non-virtualized flow. Its implementation must not
change virtualization measurements or introduce a new default layout model. Select may
map its own list values to the List it owns; consumer CSS must not select nested List
classes.

Menu — 12

Container, flow, and group dividers

Variable Applied to Current Gravity UI result when unset
--g-menu-padding Menu container padding Current size padding, --g-spacing-1 0
--g-menu-item-gap Block-axis space between adjacent regular items 0
--g-menu-group-divider-padding-block-start Content-side padding after a preceding divider Current size-specific start padding
--g-menu-group-divider-padding-block-end Content-side padding before a following divider 0 for s/m/l; current xl end padding
--g-menu-group-divider-margin-block-start Outer margin before a group divider Current size-specific start margin
--g-menu-group-divider-margin-block-end Outer margin after a group divider 0 for s/m/l; current xl end margin

Gravity UI maps these variables to its current group markup. A theme does not need to know
which list item owns the border. Block-end variables are intentional mirrors: xl Menu
already has independently meaningful end spacing.

Item geometry and states

Variable Applied to Current Gravity UI result when unset
--g-menu-item-min-height Minimum height of a regular Menu item Current intrinsic size-specific height
--g-menu-item-padding Padding of a regular Menu item Current size-specific item padding
--g-menu-item-border-radius Radius of a regular Menu item surface Current square item surface
--g-menu-item-background-color-pressed Pressed ordinary interactive item background Existing ordinary interaction cascade
--g-menu-item-text-color-disabled Disabled item text and owned foreground icon color --g-color-text-secondary
--g-menu-item-background-color-danger-hover Danger item hover and keyboard-focus background Existing generic interactive hover background

The pressed variable is for an ordinary interactive item. It must not override the current
selected, active, danger, or disabled precedence. Menu-owned foreground icons may use
currentColor or an equivalent internal mapping when a public text-color variable is set;
icon wrappers remain private.

Menu container text/background, divider color, normal item text, ordinary hover, selected,
active, and danger text continue to use global semantic tokens.

DropdownMenu — 2

Variable Applied to Current Gravity UI result when unset
--g-dropdown-menu-separator-margin Logical margin around a group separator 0.5em 0
--g-dropdown-menu-separator-color Group separator line color --g-color-line-generic-solid

DropdownMenu already creates separators when the public items API contains multiple
item groups. The separator is a semantic DropdownMenu part.

separator-margin configures the space around the line; separator-color configures the
line color.

Regular adjacent DropdownMenu items use --g-menu-item-gap. Separator margin owns all
spacing around a separator, so Menu item gap must not be added on top of it.

Menu item geometry and state variables must apply to regular DropdownMenu items without
turning a separator into an item surface. The implementation should scope or map the new
Menu API to semantic regular items rather than changing the default separator layout.
DropdownMenu continues to reuse the Popup API for its root popup and submenus.

SegmentedRadioGroup — 21

Container and option

Variable Applied to Current Gravity UI result when unset
--g-segmented-radio-group-option-inset Inset of an option surface 0
--g-segmented-radio-group-font-weight Group font weight --g-text-body-font-weight
--g-segmented-radio-group-background-color Group background Transparent
--g-segmented-radio-group-border-radius Outer group radius Current size-specific group contour
--g-segmented-radio-group-option-border-radius Individual option-surface radius Current outer-corner topology; inner corners remain square
--g-segmented-radio-group-option-divider-color Divider color between options --g-color-line-generic
--g-segmented-radio-group-option-border-color Default option border color --g-color-line-generic
--g-segmented-radio-group-option-text-color Default option text and owned icon color --g-color-text-complementary
--g-segmented-radio-group-option-text-margin-inline Inline margin around option text s/m/l/xl: 10px/13px/18px/25px

The option inset changes only the option surface. It must not increase the component's
external dimensions or change the public size geometry.

Focus, checked, and disabled

Variable Applied to Current Gravity UI result when unset
--g-segmented-radio-group-focus-outline-color Focus-visible option outline color --g-color-line-misc
--g-segmented-radio-group-focus-outline-offset Focus-visible option outline offset Current inner offset, -1px
--g-segmented-radio-group-checked-background-color Checked option surface background --g-color-base-selection
--g-segmented-radio-group-checked-border-color Checked option surface border --g-color-line-brand
--g-segmented-radio-group-checked-box-shadow Checked option surface shadow none
--g-segmented-radio-group-checked-text-color Checked option text and owned icon color --g-color-text-brand-heavy
--g-segmented-radio-group-disabled-background-color Disabled option surface background Current checked/unchecked disabled backgrounds
--g-segmented-radio-group-disabled-border-color Disabled option surface border Current checked/unchecked disabled borders
--g-segmented-radio-group-disabled-box-shadow Disabled option surface shadow none
--g-segmented-radio-group-disabled-text-color Disabled option text and owned icon color Current checked/unchecked disabled text colors

Disabled values take precedence over checked values. The component remains responsible
for the current focus mechanism and for mapping the semantic surface variables to its
existing elements or pseudo-elements.

Motion

Variable Applied to Current Gravity UI result when unset
--g-segmented-radio-group-transition-duration Existing option color and surface transitions 150ms
--g-segmented-radio-group-transition-timing-function Existing option transition timing linear

Under prefers-reduced-motion: reduce, Gravity UI continues to disable or minimize motion
regardless of the configured values.

Select — 8

Variable Applied to Current Gravity UI result when unset
--g-select-list-margin-block Block margin of a non-mobile Select list in Popup 4px on both block sides
--g-select-list-padding Padding of a non-mobile Select list container 0
--g-select-list-item-background-color-selected Selected item background Current single/multiple mode-specific backgrounds
--g-select-list-item-background-color-pressed Pressed active option background Existing selected/active interaction cascade
--g-select-list-option-padding-inline Inline option padding 8px for s/m, 12px for l/xl
--g-select-list-option-text-color-disabled Disabled option text and owned foreground icon color Current label/content/icon colors
--g-select-list-group-label-padding-inline-start Inline-start padding of a non-empty group label Current size-specific start padding
--g-select-list-group-label-padding-inline-end Inline-end padding of a non-empty group label Current size-specific end padding

The variables apply to non-mobile Select. Mobile Select keeps its current layout and
styles. Selected fallback remains different for single and multiple modes. Pressed applies
only to an enabled option and takes precedence over selected while the option is actually
pressed; when the variable is absent, the current interaction result remains unchanged.

When disabled text color is defined, it reaches the default label, custom option content
through inheritance, and owned foreground icons, including the multiple-selection tick.
When it is not defined, their existing distinct colors remain intact.

The list keeps its normal available width after padding. Supporting the new padding must
not require consumer CSS to select a private list element or set its width.
Virtualized and non-virtualized lists retain their current overflow and measurement model.

Switch — 6

Variable Applied to Current Gravity UI result when unset
--g-switch-track-background-color-pressed Unchecked pressed track background Existing unchecked interaction cascade
--g-switch-track-background-color-checked-hover Checked hover and pressed background Current checked brand background
--g-switch-slider-background-color Slider background --g-color-base-background
--g-switch-slider-box-shadow Slider shadow none
--g-switch-track-box-shadow-checked Checked track shadow none
--g-switch-focus-outline-offset Focus outline offset 0

Switch keeps the current s/m/l dimensions, slider translation, loader geometry,
transitions, disabled behavior, and focus outline width/color. Checked loader color keeps
following the existing checked track semantic color.

Calendar — 1

Variable Applied to Current Gravity UI result when unset
--g-date-calendar-button-border-radius Shared interactive cell in days, months, quarters, and years modes 4px

The variable is implemented in @gravity-ui/date-components. It applies consistently to
the existing cell surface in default, hover, focus, selected, disabled, and unavailable
states. Popup continues to own calendar popup radius.

Modal — 1

Variable Applied to Current Gravity UI result when unset
--g-modal-box-shadow Modal content shadow none

Modal already exposes border radius and sizing variables. Adding a shadow must not change
clipping, transition, mobile layout, or content overflow.

Dialog — 6

Variable Applied to Current Gravity UI result when unset
--g-dialog-header-font-family Header caption font family Current Text variant family per desktop/mobile mode
--g-dialog-header-font-weight Header caption font weight Current Text variant weight per desktop/mobile mode
--g-dialog-header-font-size Header caption font size Current Text variant size per desktop/mobile mode
--g-dialog-header-line-height Header caption line height Current Text variant line height per desktop/mobile mode
--g-dialog-close-button-color Owned close Button foreground Current flat Button color
--g-dialog-close-button-color-hover Owned close Button hover and keyboard-focus color Current flat Button hover/focus color

Dialog maps this API to the Text caption and close Button that it owns. Consumer CSS does
not select their classes or change Button variables globally. When Dialog variables are
absent, desktop and mobile continue to use their existing distinct Text variants and Button
defaults.

Progress — 1

Variable Applied to Current Gravity UI result when unset
--g-progress-border-radius Progress container and its clipped fill surface 3px

Existing Progress colors, sizes, text, loading animation, and reduced-motion behavior are
unchanged.

Existing API used by compound components

Compound components must use the API of the component that owns a visual surface. This is
part of keeping consumer theme CSS selector-free, but it does not require duplicate
variables.

  • DropdownMenu uses Popup background, border color, border radius, border width, and shadow
    variables, plus
    Menu geometry and state variables.
  • Select uses Popup surface variables and List item radius.
  • Breadcrumbs uses Popup radius and shadow for its menu popup.
  • Calendar popup surface remains owned by Popup.
  • Dialog maps its narrowly scoped variables to the Text and Button it owns without changing
    those components globally.

Foreground inheritance, separator exclusion, disabled precedence, and list sizing are
implementation responsibilities. They are not additional public CSS properties.

Theme scopes, portals, and nested popups

Theme authors define variables on a public theme scope, not on Gravity UI selectors:

.custom-theme {
  --g-popup-box-shadow: <custom-shadow>;
  --g-menu-item-min-height: <custom-size>;
  --g-dropdown-menu-separator-margin: <custom-spacing>;
  --g-switch-slider-box-shadow: <custom-shadow>;
}

Application-owned subscopes may unset or replace variables for narrower theme contexts.
They still must not select .g-* classes.

CSS custom properties follow normal DOM inheritance and do not automatically cross an
arbitrary portal boundary. This RFC therefore guarantees portal theming when the portal
container is inside the same public theme scope. Existing public container, popup props,
className, or style APIs may be used for a narrower scope.

Root DropdownMenu popups and nested submenu popups mounted in the same theme scope must
consume the same Popup, Menu, and DropdownMenu variables. A theme must not select the
portal container or submenu implementation classes.

Internal implementation requirements

These requirements make the public API sufficient. They do not authorize default visual
changes.

  • Read every variable at the semantic styling point that owns the property.
  • Preserve current private fallbacks for every size, mode, and state branch.
  • Apply Menu item geometry and gap to regular items without applying them to DropdownMenu
    separators.
  • Let separator margin own the complete space around a separator and keep the separator
    non-interactive.
  • Apply configured foreground colors to component-owned icons without changing default
    icon colors.
  • Keep Select padding within its current available width and keep mobile/virtualized
    behavior unchanged.
  • Map SegmentedRadioGroup variables to the existing option surface without changing layout
    or focus behavior.
  • Keep disabled state stronger than checked, selected, active, danger, hover, focus, and
    pressed states.
  • Keep Switch size geometry, translation, loader, and disabled opacity unchanged.
  • Keep all current accessibility semantics and reduced-motion handling.

Themer support

Themer integration in this RFC covers letter spacing for Caption, Body, Code, Subheader,
Header, and Display in @gravity-ui/uikit-themer, including CSS generation and round-trip
parsing.

Rollout

Implementation may land incrementally by component and package. A variable is delivered
only when:

  1. the component consumes it at the correct semantic part or state;
  2. current default computed styles and visual tests remain unchanged;
  3. size, mode, state, direction, and portal behavior are covered where relevant;
  4. the variable is documented as public CSS API;
  5. an opt-in fixture demonstrates the customized value without private selectors.

If a component cannot satisfy both the CSS API fixture and unchanged default fixtures, its
part of the rollout is deferred. The default Gravity UI style is not changed to make the
migration pass.

Definition of done

  • All 72 proposed variables are accepted or explicitly adjusted in RFC review.
  • Legacy List consumes existing --g-list-item-border-radius.
  • Every accepted variable is documented as public CSS API in its owning package.
  • Unthemed screenshots remain unchanged for every affected component.
  • Computed-style fixtures compare before/after defaults for all affected properties.
  • Size-specific, desktop/mobile, single/multiple, checked/unchecked, and
    enabled/disabled default branches remain unchanged where applicable.
  • Menu fixtures cover geometry, item gap, four logical divider spacings, pressed,
    disabled text, danger hover, and state precedence.
  • DropdownMenu fixtures use grouped public items and verify configurable
    margin/color, no stacked item gap, and no Menu item surface on the separator.
  • SegmentedRadioGroup fixtures cover focus, checked, disabled, disabled-and-checked,
    all sizes, configured motion, and reduced motion.
  • Select fixtures cover all non-mobile sizes, regular and virtualized lists, single and
    multiple modes, grouped options, disabled custom content/icons, pressed state, LTR,
    and RTL; mobile rendering and overflow remain unchanged.
  • Switch fixtures cover unchecked, checked, hover, pressed, disabled, loading, focus,
    and all sizes without changing existing geometry.
  • Calendar fixtures cover days, months, quarters, and years modes in default, hover,
    focus, selected, disabled, and unavailable states.
  • Dialog fixtures verify header typography and close-button colors without selecting
    nested Text or Button classes.
  • Root and nested popup fixtures work from a public theme scope without private portal
    selectors.
  • Themer represents all six typography letter-spacing groups.
  • An integration fixture configures the proposed API using only custom properties on a
    public theme scope and contains no Gravity UI selectors.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions