For AI agents: the complete documentation index is available at /next/zh/llms.txt, the full documentation bundle is available at /next/zh/llms-full.txt, and this page is available as Markdown at /next/zh/ui/styling-theming.md.
lynx-ui logo
lynx-ui
  • 简体中文
  • Primitives 与主题

    lynx-ui 是一个 unstyled 组件库(也称 headless)。受 Radix PrimitivesBase UI 启发,每个组件被拆分为两层:

    • Primitive 是你导入的顶层组件,如 SwitchPopoverSheet。每个 primitive 完整处理一种交互模式,但不决定外观。
    • Part 是组成 primitive 的结构化部件。SwitchSwitchTrackSwitchThumb 组成,每个 part 独立渲染、独立样式化。

    Primitive 不附带任何视觉设计,外观通过两种机制控制:

    • Styling 定义单个组件实例的外观:尺寸、间距、过渡,以及不同状态下的视觉表现。
    • Theming 定义多个组件共享的语义化 token,token 一旦调整,所有引用它的地方都会同步更新。

    本文以 Switch 为例,用 LUNA 作为主题层(你也可以用自己的 token 系统),从最基础的 primitive 组合逐步演进到可复用的 themed wrapper,分三步走:

    1. 为 Part 做样式
    2. 使用 Token
    3. 组合 Themed Wrapper

    准备

    对于 plain CSS,可以直接引入 LUNA 样式,让 token 在运行时以 CSS 变量的形式生效。

    @import '@lynx-js/luna-styles/index.css';

    对于 Tailwind,可以进一步通过 LUNA Tailwind preset 将同一套 token 暴露为 bg-primarytext-content 等 utility class。与此同时,Lynx 上的 Tailwind 还需要配合 @lynx-js/tailwind-preset 来提供运行时兼容支持。

    // tailwind.config.ts
    import { LunaPreset } from '@lynx-js/luna-tailwind';
    import LynxPreset from '@lynx-js/tailwind-preset';
    import type { Config } from 'tailwindcss';
    
    const config: Config = {
      content: ['./src/**/*.{js,jsx,ts,tsx}'],
      presets: [LynxPreset, LunaPreset],
    };
    
    export default config;
    Note

    部分 Tailwind 集成方案会自动处理 source scanning,因此并不是所有场景都需要 显式配置 content 字段。本指南中的示例在基础 Tailwind 配置之外,还使用了额外 的集成层。

    为 Part 做样式

    lynx-ui 的每个 part 都是面向应用层开放的。你负责渲染这些 part,并挂载自己的 className。底层不会存在需要你覆盖的隐藏默认样式。

    Switch 为例:

    import { Switch, SwitchThumb, SwitchTrack } from '@lynx-js/lynx-ui';
    
    export function ExampleSwitch() {
      return (
        <Switch className="switch">
          <SwitchTrack className="switch-track" />
          <SwitchThumb className="switch-thumb" />
        </Switch>
      );
    }

    primitive 提供的是交互行为与状态管理;而根节点的尺寸与布局、track 与 thumb 的 视觉外观,以及组件在不同状态下的表现,都由应用层定义。

    同样的结构,也可以直接使用 Tailwind utility 来表达。区别只在于样式规则写在哪:

    <Switch className="flex items-center w-[48px] h-[28px] rounded-full overflow-hidden">
      <SwitchTrack className="w-full h-full" />
      <SwitchThumb className="absolute w-[22px] h-[22px] rounded-full" />
    </Switch>

    关于平台属性补充一点:当 primitive 需要暴露底层平台能力时,会通过 switchProps 这类显式 prop 透传,而不是混入 styling API 中。这样既能保持 className 接口的稳定与可预期,也能保留平台层定制能力。

    为状态做样式

    带状态的 primitive 会暴露对应的状态样式钩子。它和 Radix UI、Headless UI 中常见 的 data-* 状态属性,以及 Tailwind 中的 data-* variant,本质上是同一类能力。

    lynx-ui 中,这些能力以 UI Variants 的形式暴露,对应的是 UI 状态 class:

    • ui-checked
    • ui-active
    • ui-disabled

    headless primitive 之所以既能保持无样式,又依然容易定制视觉,很大程度上正是因 为这些状态被显式暴露了出来。

    使用 plain CSS 时,可以直接通过选择器命中这些状态:

    .switch.ui-disabled {
      opacity: 0.5;
    }
    .switch-track.ui-checked {
      background-color: var(--primary);
    }
    .switch-track.ui-active {
      background-color: var(--primary-2);
    }
    .switch-thumb.ui-checked {
      transform: translateX(23px);
    }
    .switch-thumb.ui-active {
      width: 33px;
    }
    .switch-thumb.ui-checked.ui-active {
      transform: translateX(12px);
    }

    使用 Tailwind 时,同样的状态则通过 variant chain 表达:

    <SwitchTrack
      className="bg-neutral-faint
        ui-checked:bg-primary
        ui-active:bg-primary-2"
    />
    <SwitchThumb
      className="bg-primary-content
        transform-[translateX(3px)]
        ui-checked:transform-[translateX(23px)]
        ui-active:w-[33px]
        ui-checked:ui-active:transform-[translateX(12px)]"
    />
    Note

    本指南中的 transform 都写成 transform-[translateX(...)],而不是 translate-x-[...]

    这是因为 translate-x-[...] 内部依赖 CSS variable,在 Lynx 低版本 runtime 里与 transition 或 animation 一起使用时存在已知问题。虽然 @lynx-js/tailwind-preset 当前是基于 Tailwind v3,但已额外支持这种 transform 写法。

    当多个状态可能同时生效时,比如 checkedpressed,需要明确设计状态优先级。

    上面的 ui-checked:ui-active: 是有意设计的:当两个状态同时存在时,它会覆盖单独 ui-checked 的 translation,从而保证交互过程中的视觉连续性。

    无论使用选择器还是 utility,都不应当依赖 specificity 自然覆盖;状态之间的优先 关系,应当作为明确的设计决策来处理。

    使用 Token

    注意,上面的样式并没有硬编码具体颜色值,而是引用语义化 token,例如 primarycontent

    语义化 token 命名的是一个值在界面中的意图,而不是这个值本身。例如,primary 表示“当前主题下的主要强调色”,content 表示“主要前景内容”。

    在这篇指南中,你最常看到的是两种消费方式:在 CSS 属性值中使用 var(--primary)var(--content),或在 Tailwind 中使用 bg-primarytext-content 等原子类。它们最终都会解析到同一套运行时 token 值。

    语义化 token 的价值主要体现在两个方面:

    • 一致性。 如果 ButtonSwitchCheckbox 都引用 primary,它们的视觉表现会自动保持一致。修改 token,就会同步更新所有引用它的组件。
    • 可主题化。 组件依赖的是 primarycontent 这类语义角色,而不是具体颜色。主题可以在浅色、深色或品牌变体中改变这些角色的实际取值,而不需要重写组件样式。

    在实际使用中,这些语义角色的具体取值由祖先节点上的主题作用域提供。这与 自定义主题指南 中描述的模式一致:在容器层应用主题 class,让子树中的样式在同一个主题作用域下解析。

    因此,不推荐按主题为每个组件分叉写样式:

    <Button className="bg-pink-500 dark:bg-pink-300 text-white dark:text-black" />
    优先使用语义化 token,而不是色阶

    在 Tailwind 工具类中,前缀描述的是 CSS 属性,例如 bg- 表示背景色,text- 表示文本颜色。真正与主题相关的是前缀后面的取值。

    pink-500pink-300 这类取值也可能来自一套设计色阶,但这套色阶通常是全局的:无论当前是浅色、深色,还是品牌主题,pink-500 都指向同一个具体颜色。因此,它不太适合需要适配多主题的组件样式。

    相比之下,primaryprimary-content 这类语义化取值描述的是样式角色。至于这些角色最终对应什么颜色,则由当前激活的主题决定。这样组件样式可以保持稳定,而视觉结果会随主题变化。

    组件应该保持稳定,只消费语义化 token:

    <Button className="bg-primary text-primary-content" />

    主题切换则发生在容器层:

    <view className="lunaris-dark">
      <Button className="bg-primary text-primary-content" />
      <Switch className="switch">
        <SwitchTrack className="switch-track" />
        <SwitchThumb className="switch-thumb" />
      </Switch>
    </view>

    当祖先节点上的主题 class 从 lunaris-dark 切换为 lunaris-light 时,整棵子树的 token 值都会更新,而不需要重写单个组件样式。

    lynx-ui 中,这种容器级主题切换模型由 LUNA 实现。LUNA 提供了四个内置主题 class:luna-lightluna-darklunaris-lightlunaris-dark

    关于 token 命名、surface 层级以及主题覆写方式,可以参考 LUNA 主题与 Token

    组合 Themed Wrapper

    当某种样式模式开始稳定重复时,下一步通常就是基于 primitive 组合一个 themed wrapper。这样既能得到稳定、面向产品的默认 API,也不会失去底层 part 的灵活性。

    常见模式是:分别封装每个 part,在 wrapper 中写入默认值,再导出一个适用于常见 场景的便捷组合:

    import {
      Switch as SwitchPrimitive,
      SwitchThumb as SwitchThumbPrimitive,
      SwitchTrack as SwitchTrackPrimitive,
    } from '@lynx-js/lynx-ui';
    import type {
      SwitchProps,
      SwitchThumbProps,
      SwitchTrackProps,
    } from '@lynx-js/lynx-ui';
    import { clsx } from 'clsx';
    
    export function ThemedSwitchRoot({ className, ...props }: SwitchProps) {
      return (
        <SwitchPrimitive
          className={clsx(
            'flex flex-row items-center w-[48px] h-[28px] rounded-full overflow-hidden',
            'ui-disabled:opacity-50',
            className,
          )}
          {...props}
        />
      );
    }
    
    export function ThemedSwitchTrack({ className }: SwitchTrackProps) {
      return (
        <SwitchTrackPrimitive
          className={clsx(
            'size-full transition-colors duration-150 ease-out',
            'bg-neutral-faint ui-checked:bg-primary ui-active:bg-primary-2',
            className,
          )}
        />
      );
    }
    
    export function ThemedSwitchThumb({ className }: SwitchThumbProps) {
      return (
        <SwitchThumbPrimitive
          className={clsx(
            'absolute size-[22px] rounded-full bg-primary-content shadow',
            'transition-all duration-150 ease-out transform-[translateX(3px)]',
            'ui-checked:transform-[translateX(23px)]',
            'ui-active:w-[33px]',
            'ui-checked:ui-active:transform-[translateX(12px)]',
            className,
          )}
        />
      );
    }
    
    export function ThemedSwitch(props: SwitchProps) {
      return (
        <ThemedSwitchRoot {...props}>
          <ThemedSwitchTrack />
          <ThemedSwitchThumb />
        </ThemedSwitchRoot>
      );
    }

    这种形式有几个值得注意的特点:

    • 每个 part 都单独封装,因此使用方仍然可以单独覆盖其中任意部分,而不需要整体 重写。
    • ThemedSwitch 提供了覆盖常见场景的默认组合,但不会阻断 part 级别的 API。
    • 尺寸、过渡、状态视觉等默认规则只定义一次,然后在不同场景中复用。
    • token 层(bg-primarybg-neutral-faintbg-primary-content)保持不变; wrapper 做的是默认值组织,而不是重新定义视觉体系。

    这也是一种典型的 shadcn 风格构建方式:primitive 保持公开、可组合,而 themed component 则作为产品层的默认实现存在。

    下面的示例展示了这一模式在实际 UI 中的用法:通过受控 Switch 切换容器主题,并演示嵌套 surface 如何独立覆盖主题。这个 demo 也会出现在 <Switch> 组件页。

    最佳实践

    • 保持 primitive 在视觉层面的无主张,外观应由应用层定义。
    • 为每个渲染出的 part 显式编写样式,而不是依赖隐藏默认值。
    • 即使在开发早期,也优先使用语义化 token,而不是字面颜色。
    • 当多个状态可能叠加时,明确设计状态优先级。
    • 当一种样式模式真正开始重复时,再升级为 wrapper,而不是过早封装。
    • 将主题视为跨 surface 的共享视觉语言,而不是颜色堆砌。

    小结

    lynx-ui 的 primitive 在保留行为控制权的同时,也把完整的样式控制权交给了应用层。你可以直接使用 plain CSS 或 Tailwind 为 part 做样式,通过 LUNA 这样的 token 层保持视觉一致性,并在模式稳定后进一步组合 themed wrapper,实现可复用的产品层组件。

    除非另有说明,本项目采用知识共享署名 4.0 国际许可协议进行许可,代码示例采用 Apache License 2.0 许可协议进行许可。