desktop-principles
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseDesktop 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 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.
:hoverCSS - 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") }
}悬停是桌面端的主要交互提示信号,与移动端相反。当指针悬停在目标元素上却没有即时视觉反馈时,用户会觉得界面有问题:用户依赖状态来确认元素可交互,之后才会点击。所有可点击区域都必须有独特的悬停样式,理想情况下设置100-200ms的过渡动画,让变化可感知但又不会显得迟缓。
:hoverCSS - 交互元素的悬停样式:
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 in a list-heavy app is not minimalism, it is a bug.
⌘+F| Action | macOS | Windows / Linux |
|---|---|---|
| New | | |
| Close window | | |
| Quit app | | |
| Settings / Preferences | | |
| Find | | |
| Toggle (comment, sidebar...) | | |
| Save | | |
| Command palette | | |
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)
}
}桌面端用户期望应用遵循原生系统的惯例。在大量列表的应用中缺少搜索功能不是极简设计,而是一个bug。
⌘+F| 操作 | macOS | Windows / Linux |
|---|---|---|
| 新建 | | |
| 关闭窗口 | | |
| 退出应用 | | |
| 设置/偏好设置 | | |
| 查找 | | |
| 切换(注释、侧边栏...) | | |
| 保存 | | |
| 命令面板 | | |
网页端 - 正确区分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 injected via environment, Compose or -equivalent). Never duplicate state per window - reconciling diverging copies is a graveyard of bugs.
@ObservablekoinviewModel桌面端用户习惯将窗口并排显示。在以下场景中,新建窗口是合理选择:
- 任务运行时间较长,用户希望在主窗口继续工作(如渲染、导出、同步日志)。
- 用户需要对比两个并行场景(如两个文档、两个聊天窗口、两个问题工单)。
- 应用为文档型,每个文档相互独立(如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通过环境注入的对象,Compose的或类似的组件)。绝对不要为每个窗口复制状态——协调不一致的状态副本会引发大量bug。
@ObservablekoinviewModelFocus 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 () 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.
⌘K桌面端用户使用13-32英寸的屏幕,搭配高精度指针和全键盘。他们能够且希望在每个视口中获取比移动端更多的信息。使用8px基础网格(移动端为4-8px),采用固定侧边栏而非底部标签栏,为高级用户提供命令面板(),当数据需要时使用高密度数据表。Linear、Things 3和Notion是典范:信息丰富但不拥挤,每一个像素都物尽其用。
⌘KSubtle 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
快速参考:加载子技能
| Need | Load |
|---|---|
| Keyboard patterns deep-dive | |
| Multi-window state | |
| SwiftUI animations | |
| Compose Desktop | |
| 需求 | 加载路径 |
|---|---|
| 键盘模式深入学习 | |
| 多窗口状态管理 | |
| SwiftUI动画 | |
| Compose Desktop | |
Sources
参考资料
- Apple Human Interface Guidelines (macOS): https://developer.apple.com/design/human-interface-guidelines/macos
- Microsoft Fluent Design 2: https://fluent2.microsoft.design/
- GNOME Human Interface Guidelines: https://developer.gnome.org/hig/
- Apple macOS人机界面指南:https://developer.apple.com/design/human-interface-guidelines/macos
- Microsoft Fluent Design 2:https://fluent2.microsoft.design/
- GNOME人机界面指南:https://developer.gnome.org/hig/