Files
dify/packages/dify-ui/docs/styling.md
T

7.1 KiB

Styling

Component state

Prefer the primitive's data attributes for state styling, such as data-checked: or data-disabled:. Use its CSS variables for exposed dynamic values, such as popup anchor width and available height. Do not mirror primitive state in React solely to style it.

Field validity has three states: data-valid, data-invalid, or neither before validation. not-data-invalid includes the unvalidated state; it is not equivalent to data-valid. Preserve existing interaction variants when adding error styles so call-site overrides keep merging. Disabled surfaces take precedence over invalid and read-only appearance. Keep invalid state and error associations intact while limiting error colors to enabled controls.

Read-only locks the value, not the control. It stays focusable, keeps its indicators and drops hover feedback without looking disabled. Its popup opens for browsing; choosing a value and the parts that only commit or clear one are unavailable. Expose it with aria-readonly, never aria-disabled.

Where supported by the upstream part, className(state) and style(state) read that part's state. At call sites, before using a callback or React state to calculate styles, check whether data attributes or CSS variables already express the requirement. When direct state access is needed, add a short comment explaining why; for example, Tooltip and Popover can share data-popup-open on one trigger while the style needs only Popover's open state. This does not restrict conditional rendering of content.

Composite content forwards these callbacks to its styled part: for example, PopoverContent receives Popup state, not Positioner state. Native DOM props and custom convenience APIs do not automatically support state callbacks.

Use render for element composition, not merely to change styles. A callback on PopoverTrigger reads Popover state; one on a standalone Button reads Button state. Composition does not change which state a part owns.

With render={<Button />}, Base UI automatically merges the target's props without resolving its callbacks. Put callbacks on the outer primitive and use class strings and style objects on that target. With render={(props, state) => ...}, forward the received props and ref, and explicitly merge any custom classes, styles, or handlers. Do not switch to this form merely for state styling. See Base UI's styling and composition guides for the upstream contracts.

Tailwind CSS v4

When merging default classes with an incoming className, use the local cn(...) helper and put defaults first so callers can override them. Resolve state callbacks before merging their classes.

Import Tailwind from the consumer's root stylesheet as described in the Tailwind CSS v4 upgrade guide, then import the Dify UI CSS entry:

@import 'tailwindcss';
@import '@langgenius/dify-ui/styles.css';

When a workspace consumer scans Dify UI source directly, add an @source entry for the package's src/ directory using Tailwind CSS functions and directives, resolved from that consumer stylesheet:

/* Example only: resolve paths from this stylesheet. */
@source '../../../packages/dify-ui/src';
@source not '../../../packages/dify-ui/src/**/*.{spec,test}.{ts,tsx}';
@source not '../../../packages/dify-ui/src/**/*.stories.{ts,tsx}';

Writing direction

Use logical inline-direction utilities for spacing, offsets, borders, corners, and alignment. Caller overrides must use the same family: cn does not merge pl-* with ps-*.

Keep block-axis and sizing utilities unchanged. Preserve physical directions for swipe geometry and transform-based centering; explain deliberate physical exceptions with a scoped lint directive.

Border radius

Check the actual radius value in Figma before choosing a rounded-* class; matching token names do not imply matching values. For example, radius/sm = 6px requires rounded-md, not rounded-sm (4px). Prefer a standard Tailwind class with the matching value; use an arbitrary value only when none matches, such as rounded-[10px] for 10px.

Use semantic Dify tokens and existing component variants before hard-coded values or repeated primitive classes. Use an important modifier only for a tightly scoped compatibility override after the owning variant, data attribute, and selector structure cannot express the state.

Scroll padding

An item scrolled into view, by the keyboard or on open, keeps the same inset from the visible edge as it has at the ends of the list. Other rows may be clipped there; that shows more content follows.

Padding scrolls away with the content, so a scroll container with vertical padding around its items sets matching scroll padding on the same element. Primitives own this pair. A call site adds the height of a header or group label that must stay visible with the target item.

Cursor

A control does not set a cursor for its enabled state; the consuming app's base styles decide it. A control sets a cursor only for a state it owns: not-allowed when disabled, the default arrow when read-only, and the text cursor on a text entry area.

Focus indicator

src/internal/focus.ts owns the outer and inset focus-visible rings and the separated indicator for checked controls. Both ring constants include focus-visible:outline-hidden; do not repeat outline handling at their call sites. A package-wide interaction contract can have a shared style owner; visual similarity alone is not a reason to share constants. Keep component-specific selectors local.

Controls own their focus indicator, so callers never write one. That includes every trigger that opens a surface (Popover, Menu, Collapsible, Combobox, Dialog, AlertDialog, Drawer); they share one ring. Rows inside a composite defer to its highlight. Parts that only attach behavior (Tooltip, PreviewCard, and ContextMenu triggers, and Close parts) add no styles.

Text controls (Input, Textarea, InputGroup, NumberField, and the Combobox and Autocomplete input groups) draw that ring on the visible field. It replaces the hover border, and an invalid field keeps its border and fill inside it. Switch, Checkbox, and Radio leave a gap between the control and the indicator, because their checked fill is the indicator color. MenuInputGroup is the exception: Base UI marks Menu.Input with data-highlighted while the input holds the keyboard highlight, so the group draws the ring on that state and drops it once the arrow keys move the highlight into the list.

Attach the indicator to the element that visually represents focus. If a visible wrapper contains the native focus target, select that descendant state from the wrapper; for example, SliderThumb uses has-[:focus-visible] because its internal range input receives focus. Select :focus-visible, except on a text control, which selects :focus because it shows focus however it was reached.