packages/dify-ui/README.md
Shared UI primitives, design tokens, Tailwind preset, and the cn() utility consumed by Dify's web/ app.
The primitives are thin, opinionated wrappers around Base UI headless components, styled with cva + cn and Dify design tokens.
private: true— this package is consumed byweb/via the pnpm workspace and is not published to npm. Treat the API as internal to Dify, but stable within the workspace.
Already wired as a workspace dependency in web/package.json. Nothing to install.
For a new workspace consumer, add:
{
"dependencies": {
"@langgenius/dify-ui": "workspace:*"
}
}
Always import from a subpath export — there is no barrel:
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import '@langgenius/dify-ui/styles.css' // once, in the app root
Importing from @langgenius/dify-ui (no subpath) is intentionally not supported — it keeps tree-shaking trivial and makes Storybook / test coverage attribution per-primitive.
| Category | Subpath | Notes |
|---|---|---|
| Overlay | ./alert-dialog, ./context-menu, ./dialog, ./dropdown-menu, ./popover, ./select, ./toast, ./tooltip | Portalled. See Overlay & portal contract below. |
| Form | ./number-field, ./slider, ./switch | Controlled / uncontrolled per Base UI defaults. |
| Layout | ./scroll-area | Custom-styled scrollbar over the host viewport. |
| Media | ./avatar, ./button | Button exposes cva variants. |
Utilities:
./cn — clsx + tailwind-merge wrapper. Use this for conditional class composition../tailwind-preset — Tailwind v4 preset with Dify tokens. Apps extend it from their own tailwind.config.ts../styles.css — the one CSS entry that ships the design tokens, theme variables, and base reset. Import it once from the app root.All overlay primitives (dialog, alert-dialog, popover, dropdown-menu, context-menu, select, tooltip, toast) render their content inside a Base UI Portal attached to document.body. This is the Base UI default — see the upstream Portals docs for the underlying behavior. Consumers do not need to wrap anything in a portal manually.
The host app must establish an isolated stacking context at its root so the portalled overlay layer is not clipped or re-ordered by ancestor transform / filter / contain styles. In the Dify web app this is done in web/app/layout.tsx:
<body>
<div className="isolate h-full">{children}</div>
</body>
Equivalent: any root element with isolation: isolate in CSS. Without it, overlays can be visually clipped on Safari when a descendant creates a new stacking context.
Every overlay primitive uses a single, shared z-index. Do not override it at call sites.
| Layer | z-index | Where |
|---|---|---|
| Overlays (Dialog, AlertDialog, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | z-1002 | Positioner / Backdrop |
| Toast viewport | z-1003 | One layer above overlays so notifications are never hidden under a dialog. |
Rationale: during Dify's migration from legacy portal-to-follow-elem / base/modal / base/dialog overlays to this package, new and old overlays coexist in the DOM. z-1002 sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and rely on DOM order for stacking — the portal mounted later wins.
See [web/docs/overlay-migration.md](../../web/docs/overlay-migration.md) for the Dify-web migration history and the remaining legacy allowlist. Once the legacy overlays are gone, the values in this table can drop back to z-50 / z-51.
z-1003 / z-9999 / etc. overrides on primitives from this package. If something is getting clipped, the parent overlay (typically a legacy one) is the problem and should be migrated.DialogTrigger, PopoverTrigger, etc. Base UI handles focus management, scroll-locking, and dismissal.pnpm -C packages/dify-ui test — Vitest unit tests for primitives.pnpm -C packages/dify-ui storybook — Storybook on the default port. Each primitive has index.stories.tsx.pnpm -C packages/dify-ui type-check — tsgo --noEmit for this package only.Base UI can wait for element.getAnimations() to finish before it unmounts overlays, panels, and transition-driven components. Browser-based test runners can make that timing unstable, especially when tests assert final DOM state rather than animation behavior.
Set the Base UI test flag in a Vitest setup file to skip those waits:
(
globalThis as typeof globalThis & {
BASE_UI_ANIMATIONS_DISABLED: boolean
}
).BASE_UI_ANIMATIONS_DISABLED = true
packages/dify-ui/vitest.setup.ts already applies this for primitive tests.
See [AGENTS.md](./AGENTS.md) for:
cva + cn, relative imports inside the package, subpath imports from consumers).--radius/* token → Tailwind rounded-* class mapping.jotai, zustand), data fetching (ky, @tanstack/react-query, @orpc/*), i18n (next-i18next / react-i18next), and routing (next) all live in web/. This package has zero dependencies on them and must stay that way so it can eventually be consumed by other apps or extracted.web/app/components/....