desktop-principles

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Desktop Principles

桌面端设计原则

Desktop UX context. Loaded when desktop is detected (macOS, Windows, Linux desktop, web desktop). Concise rules here. Deep-dive in
references/
.

桌面端UX设计场景。当检测到桌面环境(macOS、Windows、Linux桌面、网页桌面端)时加载。 此处为精简规则,详细内容请查看
references/
目录。

Hover States Are Mandatory

悬停状态为必填项

Hover is the primary affordance signal on desktop, the inverse of mobile. A pointer hovering over a target without immediate visual feedback feels broken: users rely on
:hover
to confirm an element is interactive before committing to a click. Every clickable surface must have a distinct hover style, ideally with a 100-200ms transition so the change is perceptible without feeling sluggish.
CSS - hover styles for interactive elements:
css
.button {
  background: var(--surface);
  transition: background 120ms ease-out, transform 120ms ease-out;
}

.button:hover {
  background: var(--surface-hover);
  transform: translateY(-1px);
}

.button:active {
  transform: translateY(0);
}
SwiftUI - .onHover for macOS, .hoverEffect for iPadOS:
swift
struct ToolbarButton: View {
  @State private var hovering = false

  var body: some View {
    Image(systemName: "square.and.arrow.up")
      .padding(8)
      .background(hovering ? Color.gray.opacity(0.15) : .clear)
      .onHover { hovering = $0 }
      .animation(.easeOut(duration: 0.12), value: hovering)
      .hoverEffect(.highlight) // iPadOS pointer support, no-op on macOS
  }
}
Compose Desktop - onPointerEvent or hoverable + interactionSource:
kotlin
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ToolbarButton(onClick: () -> Unit) {
  val interactionSource = remember { MutableInteractionSource() }
  val hovered by interactionSource.collectIsHoveredAsState()

  Box(
    modifier = Modifier
      .hoverable(interactionSource)
      .background(if (hovered) Color.LightGray.copy(alpha = 0.15f) else Color.Transparent)
      .clickable(onClick = onClick)
      .padding(8.dp),
  ) { Icon(Icons.Default.Share, contentDescription = "Share") }
}

悬停是桌面端的主要交互提示信号,与移动端相反。当指针悬停在目标元素上却没有即时视觉反馈时,用户会觉得界面有问题:用户依赖
:hover
状态来确认元素可交互,之后才会点击。所有可点击区域都必须有独特的悬停样式,理想情况下设置100-200ms的过渡动画,让变化可感知但又不会显得迟缓。
CSS - 交互元素的悬停样式:
css
.button {
  background: var(--surface);
  transition: background 120ms ease-out, transform 120ms ease-out;
}

.button:hover {
  background: var(--surface-hover);
  transform: translateY(-1px);
}

.button:active {
  transform: translateY(0);
}
SwiftUI - macOS使用.onHover,iPadOS使用.hoverEffect:
swift
struct ToolbarButton: View {
  @State private var hovering = false

  var body: some View {
    Image(systemName: "square.and.arrow.up")
      .padding(8)
      .background(hovering ? Color.gray.opacity(0.15) : .clear)
      .onHover { hovering = $0 }
      .animation(.easeOut(duration: 0.12), value: hovering)
      .hoverEffect(.highlight) // iPadOS pointer support, no-op on macOS
  }
}
Compose Desktop - 使用onPointerEvent或hoverable + interactionSource:
kotlin
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ToolbarButton(onClick: () -> Unit) {
  val interactionSource = remember { MutableInteractionSource() }
  val hovered by interactionSource.collectIsHoveredAsState()

  Box(
    modifier = Modifier
      .hoverable(interactionSource)
      .background(if (hovered) Color.LightGray.copy(alpha = 0.15f) else Color.Transparent)
      .clickable(onClick = onClick)
      .padding(8.dp),
  ) { Icon(Icons.Default.Share, contentDescription = "Share") }
}

Pointer Precision

指针精度

Mouse and trackpad pointers are far more accurate than thumbs, so desktop targets can be smaller than the 44pt mobile minimum. Common ranges are 24-32px for icon buttons, 28-36px for toolbar items. WCAG 2.5.8 (AA, target size minimum) sets the absolute floor at 24x24 CSS pixels for non-mobile pointer input. Sub-24px targets need spacing or be grouped with sibling targets.
Fitts's Law in practice: the time to acquire a target shrinks with size and grows with distance. Screen edges and corners are infinite-depth targets - the cursor stops there regardless of overshoot. Put high-frequency global controls (close window, system menu, app dock) in corners and along edges. macOS menubar and Windows taskbar are textbook applications: edge-anchored, zero-overshoot acquisition.

鼠标和触控板指针的精度远高于手指,因此桌面端的交互目标可以比移动端要求的最小44pt更小。图标按钮的常见尺寸范围为24-32px,工具栏项为28-36px。WCAG 2.5.8(AA级,目标尺寸最小值)规定,非移动指针输入的绝对最小尺寸为24x24 CSS像素。小于24px的目标需要增加间距,或与同类目标分组。
实践中的费茨定律: 定位目标的时间随目标尺寸增大而缩短,随距离增大而延长。屏幕边缘和角落是“无限深度”的目标——无论指针是否超出,都会停在那里。将高频全局控件(关闭窗口、系统菜单、应用程序坞)放在角落和边缘。macOS菜单栏和Windows任务栏是典型案例:锚定在边缘,无需担心指针超出。

Keyboard Shortcuts (first-class)

键盘快捷键(一等公民)

Desktop users expect parity with native conventions. Missing
⌘+F
in a list-heavy app is not minimalism, it is a bug.
ActionmacOSWindows / Linux
New
⌘+N
Ctrl+N
Close window
⌘+W
Ctrl+W
Quit app
⌘+Q
Alt+F4
Settings / Preferences
⌘+,
Ctrl+,
Find
⌘+F
Ctrl+F
Toggle (comment, sidebar...)
⌘+/
Ctrl+/
Save
⌘+S
Ctrl+S
Command palette
⌘+K
or
⌘+Shift+P
Ctrl+K
or
Ctrl+Shift+P
Web - detect Ctrl vs Cmd correctly:
js
// Prefer event.metaKey on macOS, event.ctrlKey elsewhere.
// navigator.platform is deprecated but still pragmatic; fall back to userAgent.
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);

window.addEventListener("keydown", (e) => {
  const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
  if (cmdOrCtrl && e.key.toLowerCase() === "k") {
    e.preventDefault();
    openCommandPalette();
  }
});
SwiftUI - .keyboardShortcut binds to menu commands:
swift
Button("New Document", action: newDoc)
  .keyboardShortcut("n", modifiers: .command)

Button("Find", action: focusSearch)
  .keyboardShortcut("f", modifiers: .command)
Compose Desktop - onKeyEvent + KeyShortcut:
kotlin
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.commandShortcut(key: Key, onTrigger: () -> Unit) =
  onKeyEvent { event ->
    if (event.type == KeyEventType.KeyDown && event.isMetaPressed && event.key == key) {
      onTrigger(); true
    } else false
  }

// In MenuBar:
MenuBar {
  Menu("File") {
    Item("New", shortcut = KeyShortcut(Key.N, meta = true), onClick = ::newDoc)
    Item("Find", shortcut = KeyShortcut(Key.F, meta = true), onClick = ::focusSearch)
  }
}

桌面端用户期望应用遵循原生系统的惯例。在大量列表的应用中缺少
⌘+F
搜索功能不是极简设计,而是一个bug。
操作macOSWindows / Linux
新建
⌘+N
Ctrl+N
关闭窗口
⌘+W
Ctrl+W
退出应用
⌘+Q
Alt+F4
设置/偏好设置
⌘+,
Ctrl+,
查找
⌘+F
Ctrl+F
切换(注释、侧边栏...)
⌘+/
Ctrl+/
保存
⌘+S
Ctrl+S
命令面板
⌘+K
⌘+Shift+P
Ctrl+K
Ctrl+Shift+P
网页端 - 正确区分Ctrl与Cmd键:
js
// Prefer event.metaKey on macOS, event.ctrlKey elsewhere.
// navigator.platform is deprecated but still pragmatic; fall back to userAgent.
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);

window.addEventListener("keydown", (e) => {
  const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
  if (cmdOrCtrl && e.key.toLowerCase() === "k") {
    e.preventDefault();
    openCommandPalette();
  }
});
SwiftUI - 使用.keyboardShortcut绑定菜单命令:
swift
Button("New Document", action: newDoc)
  .keyboardShortcut("n", modifiers: .command)

Button("Find", action: focusSearch)
  .keyboardShortcut("f", modifiers: .command)
Compose Desktop - 使用onKeyEvent + KeyShortcut:
kotlin
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.commandShortcut(key: Key, onTrigger: () -> Unit) =
  onKeyEvent { event ->
    if (event.type == KeyEventType.KeyDown && event.isMetaPressed && event.key == key) {
      onTrigger(); true
    } else false
  }

// In MenuBar:
MenuBar {
  Menu("File") {
    Item("New", shortcut = KeyShortcut(Key.N, meta = true), onClick = ::newDoc)
    Item("Find", shortcut = KeyShortcut(Key.F, meta = true), onClick = ::focusSearch)
  }
}

Multi-Window Patterns

多窗口模式

Desktop users keep windows side by side. A new window is the right answer when:
  • A task runs long enough that the user wants to keep working in the main window (rendering, export, sync log).
  • The user is comparing two parallel contexts (two documents, two chats, two issues).
  • The app is document-based and each document is a peer (Pages, Figma files, Xcode projects).
A new window is the wrong answer for transient confirmations, brief settings panels, or anything that can live in a sheet or popover.
SwiftUI - WindowGroup for document-style, Window for singletons:
swift
@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup("Document") { DocumentView() } // peer windows, one per doc

    Window("Inspector", id: "inspector") { InspectorView() }
      .windowResizability(.contentSize) // tracks intrinsic content size

    Settings { SettingsView() } // ⌘+, target on macOS
  }
}
Compose Desktop - Window composables, application scope:
kotlin
fun main() = application {
  val docs = remember { mutableStateListOf(Document()) }

  docs.forEach { doc ->
    Window(onCloseRequest = { docs.remove(doc) }, title = doc.title) {
      DocumentView(doc)
    }
  }

  if (showInspector) {
    Window(onCloseRequest = { showInspector = false }, title = "Inspector") {
      InspectorView()
    }
  }
}
State sharing: windows are views over the same model. Hold the source of truth in a singleton or a DI-scoped object (SwiftUI
@Observable
injected via environment, Compose
koin
or
viewModel
-equivalent). Never duplicate state per window - reconciling diverging copies is a graveyard of bugs.

桌面端用户习惯将窗口并排显示。在以下场景中,新建窗口是合理选择:
  • 任务运行时间较长,用户希望在主窗口继续工作(如渲染、导出、同步日志)。
  • 用户需要对比两个并行场景(如两个文档、两个聊天窗口、两个问题工单)。
  • 应用为文档型,每个文档相互独立(如Pages、Figma文件、Xcode项目)。
对于临时确认、简短设置面板,或任何可以放在表单/弹出层中的内容,新建窗口是错误选择。
SwiftUI - 文档式应用使用WindowGroup,单例窗口使用Window:
swift
@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup("Document") { DocumentView() } // peer windows, one per doc

    Window("Inspector", id: "inspector") { InspectorView() }
      .windowResizability(.contentSize) // tracks intrinsic content size

    Settings { SettingsView() } // ⌘+, target on macOS
  }
}
Compose Desktop - 使用Window组件,应用级作用域:
kotlin
fun main() = application {
  val docs = remember { mutableStateListOf(Document()) }

  docs.forEach { doc ->
    Window(onCloseRequest = { docs.remove(doc) }, title = doc.title) {
      DocumentView(doc)
    }
  }

  if (showInspector) {
    Window(onCloseRequest = { showInspector = false }, title = "Inspector") {
      InspectorView()
    }
  }
}
状态共享: 窗口是同一模型的不同视图。应将真实数据源保存在单例或依赖注入作用域对象中(如SwiftUI通过环境注入的
@Observable
对象,Compose的
koin
或类似
viewModel
的组件)。绝对不要为每个窗口复制状态——协调不一致的状态副本会引发大量bug。

Focus Management

焦点管理

Keyboard navigation is a first-class input on desktop. Tab order must be sane, focus rings must be visible, and removing them without an alternative is an accessibility regression.
SwiftUI - @FocusState drives field focus:
swift
struct LoginForm: View {
  enum Field { case email, password }

  @State private var email = ""
  @State private var password = ""
  @FocusState private var focused: Field?

  var body: some View {
    VStack {
      TextField("Email", text: $email)
        .focused($focused, equals: .email)
        .onSubmit { focused = .password }
      SecureField("Password", text: $password)
        .focused($focused, equals: .password)
        .onSubmit(submit)
    }
    .onAppear { focused = .email }
  }
}
Compose - FocusRequester + LocalFocusManager:
kotlin
val emailFocus = remember { FocusRequester() }
val passwordFocus = remember { FocusRequester() }
val focusManager = LocalFocusManager.current

TextField(
  value = email, onValueChange = { email = it },
  modifier = Modifier.focusRequester(emailFocus).focusable(),
  keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
)

TextField(
  value = password, onValueChange = { password = it },
  modifier = Modifier.focusRequester(passwordFocus).focusable(),
  keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); submit() }),
)

LaunchedEffect(Unit) { emailFocus.requestFocus() }
Web - tabindex + :focus-visible:
css
.button {
  /* Never `outline: none` without an alternative. */
}

.button:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}
html
<!-- tabindex="0" puts a non-interactive element in tab order -->
<div role="button" tabindex="0" class="button">Custom button</div>

键盘导航是桌面端的一等输入方式。Tab键的切换顺序必须合理,焦点环必须可见,若无替代方案就移除焦点环会导致可访问性退化。
SwiftUI - 使用@FocusState管理输入框焦点:
swift
struct LoginForm: View {
  enum Field { case email, password }

  @State private var email = ""
  @State private var password = ""
  @FocusState private var focused: Field?

  var body: some View {
    VStack {
      TextField("Email", text: $email)
        .focused($focused, equals: .email)
        .onSubmit { focused = .password }
      SecureField("Password", text: $password)
        .focused($focused, equals: .password)
        .onSubmit(submit)
    }
    .onAppear { focused = .email }
  }
}
Compose - 使用FocusRequester + LocalFocusManager:
kotlin
val emailFocus = remember { FocusRequester() }
val passwordFocus = remember { FocusRequester() }
val focusManager = LocalFocusManager.current

TextField(
  value = email, onValueChange = { email = it },
  modifier = Modifier.focusRequester(emailFocus).focusable(),
  keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
)

TextField(
  value = password, onValueChange = { password = it },
  modifier = Modifier.focusRequester(passwordFocus).focusable(),
  keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); submit() }),
)

LaunchedEffect(Unit) { emailFocus.requestFocus() }
网页端 - 使用tabindex + :focus-visible:
css
.button {
  /* Never `outline: none` without an alternative. */
}

.button:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}
html
<!-- tabindex="0" puts a non-interactive element in tab order -->
<div role="button" tabindex="0" class="button">Custom button</div>

Information Density

信息密度

Desktop users sit on a 13-32 inch screen with a precise pointer and full keyboard. They can - and want to - parse more information per viewport than on mobile. Use an 8px base grid (vs 4-8px mobile), persistent sidebars instead of bottom tabs, command palettes (
⌘K
) for power users, and dense data tables when the data warrants it. Linear, Things 3, and Notion are the touchstones: information-rich without feeling cramped, every pixel earns its keep.

桌面端用户使用13-32英寸的屏幕,搭配高精度指针和全键盘。他们能够且希望在每个视口中获取比移动端更多的信息。使用8px基础网格(移动端为4-8px),采用固定侧边栏而非底部标签栏,为高级用户提供命令面板(
⌘K
),当数据需要时使用高密度数据表。Linear、Things 3和Notion是典范:信息丰富但不拥挤,每一个像素都物尽其用。

Subtle Animations Doctrine

轻量动画原则

Desktop apps are stared at for hours. Animations that feel delightful once become unbearable on the hundredth repetition. Prefer short, purely functional motion: opacity and small translations under 200ms, no bounces on routine interactions, no playful overshoots on hover. Save expressive motion for one-shot moments (onboarding, success states), never daily UI.
css
/* BAD - every hover bounces for 600ms, exhausting after the third use */
.card {
  transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.card:hover { transform: scale(1.05); }
css
/* GOOD - 100ms opacity, almost subliminal, never tires */
.card {
  opacity: 0.92;
  transition: opacity 100ms ease-out;
}
.card:hover { opacity: 1; }

桌面端应用会被用户长时间盯着看。一次看起来愉悦的动画,重复上百次后会变得难以忍受。优先选择简短、纯功能性的动画:透明度变化和小幅度位移,时长控制在200ms以内,常规交互不要有弹跳效果,悬停时不要有夸张的过度动画。将富有表现力的动画留给一次性场景(如引导页、成功状态),不要用于日常UI。
css
/* 错误示例 - 每次悬停都有600ms的弹跳效果,第三次使用后就会让人疲惫 */
.card {
  transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.card:hover { transform: scale(1.05); }
css
/* 正确示例 - 100ms透明度变化,几乎不易察觉,永远不会让人厌烦 */
.card {
  opacity: 0.92;
  transition: opacity 100ms ease-out;
}
.card:hover { opacity: 1; }

Anti-Patterns (BAD / GOOD)

反模式(错误/正确示例)

1. Hiding navigation behind a hamburger on desktop

1. 桌面端将导航隐藏在汉堡菜单后

html
<!-- BAD - 1440px viewport, infinite room, but nav is collapsed -->
<header>
  <button class="hamburger" aria-label="Menu"></button>
</header>
<nav class="drawer hidden">...</nav>
html
<!-- GOOD - persistent sidebar on desktop, collapsible if the user wants it -->
<aside class="sidebar">
  <nav>
    <a href="/inbox">Inbox</a>
    <a href="/projects">Projects</a>
    <a href="/archive">Archive</a>
  </nav>
  <button class="collapse-toggle" aria-label="Collapse sidebar"></button>
</aside>
html
<!-- 错误示例 - 1440px视口,空间充足,但导航却被折叠 -->
<header>
  <button class="hamburger" aria-label="Menu"></button>
</header>
<nav class="drawer hidden">...</nav>
html
<!-- 正确示例 - 桌面端使用固定侧边栏,用户可按需折叠 -->
<aside class="sidebar">
  <nav>
    <a href="/inbox">Inbox</a>
    <a href="/projects">Projects</a>
    <a href="/archive">Archive</a>
  </nav>
  <button class="collapse-toggle" aria-label="Collapse sidebar"></button>
</aside>

2. No keyboard shortcuts for primary actions

2. 主要操作无键盘快捷键

jsx
// BAD - "New" is buried in a menu, no shortcut, every creation is 3 clicks
<Toolbar>
  <Menu>
    <MenuItem onClick={newDoc}>New document</MenuItem>
  </Menu>
</Toolbar>
jsx
// GOOD - ⌘N baseline, surfaced in the menu, mirrored on the toolbar tooltip
<Toolbar>
  <button onClick={newDoc} title="New document (⌘N)">
    <PlusIcon />
  </button>
</Toolbar>
// Bound globally:
useShortcut("mod+n", newDoc);
useShortcut("mod+s", save);
useShortcut("mod+f", focusSearch);
jsx
// 错误示例 - "新建"藏在菜单中,无快捷键,每次创建需点击3次
<Toolbar>
  <Menu>
    <MenuItem onClick={newDoc}>New document</MenuItem>
  </Menu>
</Toolbar>
jsx
// 正确示例 - 基础快捷键⌘N,在菜单中显示,工具栏按钮提示也同步显示
<Toolbar>
  <button onClick={newDoc} title="New document (⌘N)">
    <PlusIcon />
  </button>
</Toolbar>
// 全局绑定:
useShortcut("mod+n", newDoc);
useShortcut("mod+s", save);
useShortcut("mod+f", focusSearch);

3. Removing focus rings without an alternative

3. 无替代方案就移除焦点环

css
/* BAD - keyboard users now have no idea where focus is */
button:focus { outline: none; }
css
/* GOOD - :focus-visible keeps mouse clicks ring-free, keyboard navigation visible */
button:focus { outline: none; }
button:focus-visible {
  outline: 2px solid var(--brand-500);
  outline-offset: 2px;
  border-radius: 6px;
}

css
/* 错误示例 - 键盘用户现在无法知道焦点位置 */
button:focus { outline: none; }
css
/* 正确示例 - :focus-visible让鼠标点击无焦点环,键盘导航时焦点可见 */
button:focus { outline: none; }
button:focus-visible {
  outline: 2px solid var(--brand-500);
  outline-offset: 2px;
  border-radius: 6px;
}

Quick Reference: Loading sub-skills

快速参考:加载子技能

NeedLoad
Keyboard patterns deep-dive
references/keyboard-patterns.md
Multi-window state
references/multi-window.md
SwiftUI animations
../swiftui-motion/SKILL.md
Compose Desktop
../compose-multiplatform/SKILL.md
(when desktop is a CMP target)

需求加载路径
键盘模式深入学习
references/keyboard-patterns.md
多窗口状态管理
references/multi-window.md
SwiftUI动画
../swiftui-motion/SKILL.md
Compose Desktop
../compose-multiplatform/SKILL.md
(当桌面端为CMP目标时)

Sources

参考资料