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.