跳到主要内容
menumenu tanreiexpand_morecontrasttranslate
tanreiexpand_more
开始使用WebReactSwiftUIJetpack ComposeFlutterReact Native预设你自己的设计系统与 AI 代理一起使用 Tanrei品牌设计令牌设计系统与定制Theme Studio颜色字体排印图标形状层级动效交互状态行为布局无障碍文字颜色令牌字阶令牌形状令牌层级令牌动效令牌状态令牌间距与度量令牌组件按钮主操作按钮分段按钮按钮组带菜单的按钮工具栏复制按钮复选框单选按钮开关纸片(Chip)菜单滑块滑块字段日历DateTimePicker字段组单选组复选框组下拉选择框下拉选择字段菜单栏文件上传文件选择器 进阶放置区文本输入框 进阶文本字段纸片字段字段组合框 进阶组合框字段数字输入框 进阶数字字段颜色输入框 进阶颜色字段一次性验证码输入框 进阶一次性验证码字段嵌入式文本输入框 进阶聊天输入栏卡片分隔线模态层 进阶对话框列表列表行底部面板侧边面板轮播代码块表格弹出框步骤折叠面板描述列表图示应用外壳页头树视图时间线图片预览调整手柄可调整大小的框滚动区域徽标进度指示器计量条数字滚动器加载指示器消息条(Snackbar)工具提示键盘快捷键标签(Tag)行内代码提示块(Callout)头像空状态标签组(Tag group)骨架屏文件项色块跑马灯文本标签页标签页视图应用栏导航栏导航抽屉搜索链接分页上一页/下一页链接步骤条面包屑目录链接卡片FAB 菜单状态Tanrei 是如何构建的参与贡献许可与致谢
首页组件卡片

卡片

把一组相关内容与背景区分开来;整张卡片也可以是一个操作(打开它所展示的内容),或是一个指向该内容的链接。

content_copy以 Markdown 格式查看
类别
容器
令牌
comp.card
层级
基础

概览

Web
tanrei-card
schedule
SwiftUI
TanreiCard
schedule
Jetpack Compose
TanreiCard
schedule
Flutter
TanreiCard
schedule
React Native
Card
schedule

生成自 docs/status/status.json, specs/card.spec.json

变体

Outlined
营收比上一季度增长 12%。
Filled
营收比上一季度增长 12%。
Elevated
营收比上一季度增长 12%。

卡片有三种形态。普通卡片是一个容器:内容保留各自的语义,里面可以放按钮和链接。可交互卡片是一个按钮。链接卡片,也就是带 href 的卡片,是一个链接。激活卡片后用户会前往另一个页面或地址时,用链接卡片;事情就在当前位置发生时,用可交互卡片。

用法

Web 和 React 的示例展示的是当前规格。SwiftUI、Compose、Flutter 和 React Native 待跟进:它们提供普通卡片和可交互卡片,还没有链接卡片。这些平台的示例展示的是它们目前提供的内容。

<!-- 不是操作的卡片:内容保留各自的语义,里面可以放按钮。 -->
<tanrei-card>
  <h3 class="tanrei-text-heading-5">季度报告</h3>
  <p class="tanrei-text-body">营收比上一季度增长 12%。</p>
  <tanrei-button variant="text">分享</tanrei-button>
</tanrei-card>

<!-- 一个操作:整张卡片是一个按钮,以 label 命名。里面不放任何可交互的元素。 -->
<tanrei-card variant="elevated" interactive label="季度报告">…</tanrei-card>

<!-- 一个链接:有了 href,整张卡片就是一个锚元素,以 label 命名。里面不放任何可交互的元素。 -->
<tanrei-card href="/reports/q3/" label="季度报告">…</tanrei-card>

<!-- 在新标签页中打开的链接要用你自己的文字说明这一点。卡片不绘制任何标记:图标请放进你的内容里。 -->
<tanrei-card href="https://example.com/reports/q3.pdf" target="new" target-label="在新标签页中打开" label="季度报告">
  <h3 class="tanrei-text-heading-5">季度报告</h3>
  <span class="tanrei-icon" aria-hidden="true">open_in_new</span>
</tanrei-card>

<!-- inset 默认为 true,所以对应的 HTML 属性取其反义:flush 让照片一直铺到边缘。 -->
<tanrei-card flush>
  <img src="cover.jpg" alt="" />
  <div style="padding: var(--tanrei-comp-card-container-padding)">…</div>
</tanrei-card>

<script type="module">
  import '@tanrei/web/components/card';
  document.querySelector('[interactive]').addEventListener('click', openReport);

  // 链接卡片会在浏览器前往该地址之前触发 click。客户端路由会取消它。
  document.querySelector('tanrei-card[href]').addEventListener('click', (event) => {
    event.preventDefault();
    router.push(event.currentTarget.href);
  });
</script>
import { Card } from '@tanrei/react';

<Card variant="filled">
  <h3 className="tanrei-text-heading-5">季度报告</h3>
  <p className="tanrei-text-body">营收比上一季度增长 12%。</p>
</Card>
<Card interactive label="季度报告" onClick={openReport}>…</Card>
<Card href="/reports/q3/" label="季度报告">…</Card>
<Card href="https://example.com/reports/q3.pdf" target="new" targetLabel="在新标签页中打开" label="季度报告">…</Card>
<Card inset={false}>…</Card>
import Tanrei

TanreiCard(variant: .filled) {
    Text("季度报告")
    Text("营收比上一季度增长 12%。")
}

// 接受 action: 的初始化方法创建的就是可交互卡片。
TanreiCard(label: Text("季度报告"), action: openReport) { ReportSummary() }
    .disabled(isLoading)
import io.github.chnotchy.tanrei.components.TanreiButton
import io.github.chnotchy.tanrei.components.TanreiCard
import io.github.chnotchy.tanrei.primitives.TanreiText
import io.github.chnotchy.tanrei.tokens.TanreiCardVariant

// 没有 onClick 的卡片不是操作,所以里面可以放按钮。
TanreiCard(variant = TanreiCardVariant.Filled) {
    TanreiText("季度报告")
    TanreiText("营收比上一季度增长 12%。")
    TanreiButton(text = "分享", onClick = ::share)
}

// 带 onClick 的重载是一个操作。enabled = false 会禁用它。
TanreiCard(onClick = ::openReport, contentDescription = "季度报告") {
    TanreiText("季度报告")
    TanreiText("营收比上一季度增长 12%。")
}
import 'package:tanrei/tanrei.dart';

TanreiCard(
  variant: TanreiCardVariant.filled,
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: <Widget>[
      const Text('季度报告'),
      const Text('营收比上一季度增长 12%。'),
      TanreiButton(onPressed: share, child: const Text('分享')),
    ],
  ),
);

// onTap 不为 null 时,整张卡片是一个操作;enabled: false 会禁用它。
TanreiCard(
  onTap: openReport,
  semanticLabel: '季度报告',
  child: const ReportSummary(),
);
import { Text } from 'react-native';
import { Card, useContentColor } from '@tanrei/react-native';

// 纯字符串会以 body 样式和卡片的内容颜色绘制。
<Card variant="filled">营收比上一季度增长 12%。</Card>

// 有了 onPress,卡片就是一个操作,以它的标题命名。
<Card accessibilityLabel="季度报告" onPress={openReport}>
  <ReportSummary />
</Card>

// 你自己的文本要读取卡片的内容颜色:React Native 没有可继承的文本颜色。
function ReportSummary() {
  const color = useContentColor();
  return <Text style={{ color }}>营收比上一季度增长 12%。</Text>;
}

规格

铺满边缘、可交互、禁用

铺满边缘:内容一直延伸到卡片边缘。
季度报告
可交互:把指针悬停在上面,用 Tab 聚焦,再按下它。
季度报告
可交互,并且已禁用。

链接卡片

按钮
链接卡片。按 Enter 前往目标,浏览器的链接菜单对它同样有效。
列表
在新标签页中打开。图标是内容的一部分。
按钮
禁用的链接卡片:无处可去。

构成

构成

绘制在 Web 包所绘的测试样例 outlined 之上。
部件 说明
1 container The shape that holds the content, clips it to its corners and, for an interactive card, carries hover, focus and press.
2 outline · 可选 The border of the outlined variant.
3 content Whatever the product puts in the card. The card does not lay it out; it only insets it, clips it and hands it a content color.
4 focus-ring · 可选 Gap-then-ring indicator shown for keyboard focus of an interactive card.

属性

属性 类型 默认值 说明
variant elevated | filled | outlined "outlined" How the card separates from its background: by depth (elevated), by tone (filled) or by an edge (outlined). Pick by the background, not by importance. A design system may add values.
interactive boolean false Makes the whole card one pressable action (it emits click). Its content must then contain no other interactive element. A card whose action is to go somewhere takes href instead and is a link, not a button.
disabled boolean false Prevents interaction and removes an interactive card or a link card from the focus order. Ignored unless interactive is true or href is set: a card that is neither an action nor a link cannot be disabled.
href string "" The destination: an address as the platform's links take one (a URL; on the web a path or a fragment as well). Not empty, it makes the whole card one link — a link card — whether interactive is set or not: the link role, one tab stop, activated by pointer, touch or Enter and not by Space, opened by the platform as its own links are, and drawn exactly as an interactive card is (CRD-19, CRD-20). Its content must then contain no other interactive element, as an interactive card's.
target same | new "same" Where the destination opens. same: where the person is — the same tab on the web, the app's own handling of the address elsewhere. new: a new context — a new tab or window on the web, outside the current screen elsewhere — which the card says through targetLabel; the card draws no mark, because its content is the product's (CRD-21). Ignored without href.
targetLabel string "" What assistive technology reads after the link's name while target is new ("opens in a new tab"). Tanrei ships no words of its own, so the product gives them in its own language, as it gives a chip its removeLabel. Required while target is new; ignored otherwise.
inset boolean true Insets the content by the card's padding. Set it to false for content that reaches the card's edges (a photo on top); that content pads its own text.
label string "" The accessible name. For an interactive card or a link card, normally the text of its heading; without it the whole content becomes the name. For a card that is neither, a label makes it a named group.

插槽

插槽 说明
default The content: any layout of media, text and — only when interactive is false and href is empty — actions.

事件

事件 说明
click An interactive card or a link card was activated by pointer, touch, keyboard or assistive technology. A link card emits it before its destination opens; a product that takes the navigation itself (a client-side router) cancels it (CRD-20).

状态

行为

以 EARS 形式写成的需求。每个平台的实现都按这些 ID 逐条测试。需求原文取自规格文件,原样以英文呈现。

ID 需求
CRD-1 WHEN the user activates an interactive card by pointer, touch, Enter or Space — a link card (CRD-19) by pointer, touch or Enter — THE card SHALL emit click exactly once.
CRD-2 WHILE disabled is true and either interactive is true or href is not empty THE card SHALL NOT emit click, SHALL NOT be focusable and SHALL expose the disabled state to assistive technology.
CRD-3 WHILE an interactive card is hovered, keyboard-focused or pressed THE card SHALL render its container, outline, elevation, content color and opacity from the component tokens of the highest-precedence active state, with precedence pressed over focused over hovered; focus gained by pointer or touch SHALL NOT select the focused state.
CRD-4 WHEN an interactive card receives keyboard focus THE card SHALL show the focus ring without animation; focus gained by pointer or touch SHALL NOT show it.
CRD-5 WHERE the visual size of an interactive card is below the minimum target THE card SHALL extend its hit area on both axes to sys.target.touch for touch input and sys.target.pointer for fine pointers, without changing its visual size; a platform that cannot tell the input type SHALL use sys.target.touch.
CRD-6 WHILE disabled is true and either interactive is true or href is not empty THE card SHALL render its container, outline, elevation, content color and opacity from the disabled component tokens of its variant.
CRD-7 WHEN variant names a value that the active design system does not have THE card SHALL render with the default value's tokens, and in debug builds report the unknown value.
CRD-8 WHEN the card is pressed by pointer, touch, Enter or Space THE card SHALL show the pressed state without delay and SHALL keep it visible for at least sys.motion.duration.press-hold, even when the press ends sooner. A press — held, or moved with the pointer down — SHALL NOT select the card's text nor start a drag of it: the card is one action, as a button is, and the platform keeps a button's text from selection.
CRD-9 WHILE pressed THE card SHALL answer as sys.behavior.pressed-feedback names: overlay applies the pressed tokens to the whole container at once; ripple grows the pressed state layer from the point of contact (from the center for keyboard activation); dim applies the pressed opacity to the whole control and draws no state layer; none draws no answer to the press. Under ripple and dim only the container color stays with the state under the press; content color, outline, elevation, opacity and shape still come from the pressed tokens. Under none every part stays with the state under the press. The ripple grows over comp.card.motion.feedback-duration with comp.card.motion.state-easing, fades over comp.card.motion.state-duration with comp.card.motion.state-easing once the press has ended and press-hold has passed, and appears whole where motion is reduced.
CRD-10 THE card SHALL ease its container corners by sys.shape.corner-smoothing (0 is a quarter circle, 0.6 the iOS continuous corner; a platform with only the OS curve draws it for any amount above 0) and show its keyboard focus as sys.behavior.focus-style names (ring or glow); a platform that cannot draw a choice SHALL fall back to the first value and state that in its README.
CRD-11 WHILE interactive is false and href is empty THE card SHALL NOT be focusable, SHALL NOT emit click, SHALL NOT draw a hover, focus or press answer, SHALL ignore disabled, and SHALL render from the resting tokens of its variant.
CRD-12 WHILE inset is true THE card SHALL place its content comp.card.container.padding inside the container on every side; WHILE inset is false the content SHALL reach the container's edges. In both cases THE card SHALL clip its content to the container's shape, draw the outline of the outlined variant (comp.card.outline.width wide) above the content, and grow with its content: the card has no height of its own.
CRD-13 THE card SHALL hand its content the content color of its variant and state (comp.card.outlined.content.color for outlined at rest, and so on) as the platform's inherited foreground (the web's inherited color, SwiftUI's foreground style, Compose's content color, Flutter's default text and icon style); on React Native, which has no inherited text color, it SHALL provide the color through the Tanrei context that Tanrei's text components read. Plain text given as content, where the platform must style it, SHALL take sys.typescale.body.
CRD-14 WHILE interactive is true THE card SHALL be exactly one focusable element with the button role (the link role WHILE href is not empty, CRD-19), named by label, over a name the product gives it on the element (aria-label or aria-labelledby), or, WHILE label is empty, by the name the product gives it on the element — aria-label, or aria-labelledby naming an element — or else by the text of its content merged in reading order, and in debug builds SHALL report, WHILE it has neither label nor a name of the product's, that it has no label; WHILE interactive is false and href is empty THE card SHALL have no role of its own and leave its content's semantics untouched, except that WHERE label is set it SHALL be a group named by label.
CRD-15 WHILE interactive is true or href is not empty THE card's content SHALL contain no interactive element (button, link, field, toggle or anything else focusable); a platform that can inspect its content (the web, on every slot change) SHALL report one in debug builds, and a platform that cannot SHALL state the rule in its README and docs page.
CRD-16 WHEN the elevation or the colors of an interactive card change between states THE card SHALL animate the change over comp.card.motion.state-duration with comp.card.motion.state-easing, and SHALL change them at once where motion is reduced.
CRD-17 WHEN the layout direction is right-to-left THE card SHALL apply its padding and clipping by logical start and end so that it mirrors, SHALL grow a ripple from the point of contact as in left-to-right, and SHALL NOT mirror its content itself: the content follows the layout direction.
CRD-18 THE card SHALL take its layout direction from where it is used — the direction of the nearest ancestor that sets one, and the platform's own where none does — and SHALL have no direction setting of its own; WHEN that direction changes while the card is shown THE card SHALL draw itself for the new direction at once, without a motion of its own, keeping what it shows and its state.
CRD-19 WHILE href is not empty THE card SHALL be a link card, whether interactive is true or false: exactly one focusable element with the link role and not the button role, named as CRD-14 names an interactive card, its content presentational and held to CRD-15; the platform's own link to href (the web's anchor element; SwiftUI's Link; on Compose, Flutter and React Native an element with the platform's link semantics whose address is opened by the platform's opener), so that whatever the platform gives a link — on the web its context menu, opening in a new tab or window, copying and dragging its address, and the address shown before it is followed — is the platform's, and not the card's to draw or to take over; and everything this spec says of an interactive card other than its role and its keys — its states, focus ring, minimum target, press answer, corners, motion and disabled look (CRD-3 to CRD-6, CRD-8 to CRD-10, CRD-16) — SHALL hold for a link card from the same tokens, with no look of its own for a link and none for a destination already visited.
CRD-20 WHEN the user activates a link card by pointer, touch or Enter THE card SHALL emit click exactly once and then open href where target names, unless the product cancelled that click, in which case it SHALL open nothing: the product's own router has taken the navigation. It SHALL show the pressed state as CRD-8 and CRD-9 say and SHALL NOT hold the navigation back for sys.motion.duration.press-hold. THE card SHALL NOT activate a link card on Space, which on the web scrolls the page as it does on any link, and SHALL NOT show the pressed state for it; WHERE the platform gives its links other keys or gestures (on the web a modified or a middle click that opens a new tab, on Apple platforms a long press that previews) THE card SHALL leave them to the platform.
CRD-21 WHILE the card is a link card and target is new THE card SHALL open href in a new context — on the web a new tab or window that is given no handle to the page that opened it; on the other platforms outside the screen the person is on, as the platform's opener decides. THE card SHALL draw no mark of its own for it, because its content is the product's to lay out (CRD-12): a product that shows that the card opens elsewhere puts the glyph open_in_new of the system's icon set in the content, through the icon helper. WHILE target is new THE card SHALL expose targetLabel as its description, read after its name and role, and WHILE targetLabel is empty SHALL still render and in debug builds report that a link that opens a new context does not say so. WHILE target is same THE card SHALL ignore targetLabel.
CRD-22 WHILE href is not empty and disabled is true THE card SHALL NOT be a link that can be followed: it SHALL render as CRD-6 says, SHALL expose the link role with the disabled state and no destination, SHALL NOT be focusable, SHALL NOT emit click, and SHALL open nothing by any means the platform gives a link (on the web the anchor carries no href, so there is no context menu entry, no drag and no new tab for it).
CRD-23 WHEN target names a value the card does not define THE card SHALL behave as same, and in debug builds report the unknown value. IF href is only white space, or names a script to run rather than a place to go (the web's javascript: scheme), or cannot be read as an address by the platform, THE card SHALL be the card its interactive makes it, without a destination, and in the last two cases report it in debug builds.
CRD-24 WHERE the platform has ARIA attributes (the web), WHEN the product sets an aria- attribute on the host, by the attribute or by the ARIA property that reflects it, THE card SHALL move it to the data-aria- attribute of the same name, so that the host is no second node with its name, description or state, and SHALL expose it on the element that carries its role — an id reference by the elements it names in the host's tree or a tree around it, an error message as part of the description while the card is invalid — except those the card owns (aria-disabled), which it SHALL move and not expose. WHEN the product removes the aria- attribute or its data-aria- twin, or sets the ARIA property that reflects it (ariaLabel, ariaDescribedByElements…) to null, THE card SHALL take it off that element. WHEN the product sets an aria- attribute the card owns THE card SHALL report it in debug builds.

各平台名称对照

对等并不意味着 API 完全相同。同一份契约,由各平台以自己的惯用方式表达。

规格 Web SwiftUI Jetpack Compose Flutter React Native
组件 tanrei-card TanreiCard TanreiCard TanreiCard Card
variant · 属性 variant variant variant variant variant
interactive · 属性 interactive action onClick onTap onPress
disabled · 属性 disabled disabled enabled enabled disabled
href · 属性 href destination href href href
target · 属性 target target target target target
targetLabel · 属性 targetLabel targetLabel targetLabel targetSemanticLabel targetLabel
inset · 属性 inset inset inset inset inset
label · 属性 label label contentDescription semanticLabel accessibilityLabel
click · 事件 click action onClick onTap onPress
备注 The content is the default slot. inset is a boolean property that defaults to true; its attribute is the inverse flush, because an HTML boolean attribute cannot default to true. click fires only while interactive is true and disabled is false. With href the shadow root holds an <a part="container"> around the slot in place of the element with role button; click then fires for a link card too. The link form: href is the href attribute; target is the target attribute with the values same and new (the HTML spellings _self and _blank are read as those two; anything else is same and reported); targetLabel is the target-label attribute. Web-only, forwarded to the anchor and to nothing else: rel (to which new adds noopener) and download. Cancelling click (preventDefault()) cancels the navigation, which is how a client-side router takes it; a modified or a middle click is the browser's. A disabled link is the anchor without href, with role="link" and aria-disabled="true", as the navigation drawer's destination already is. Space is not handed to the press controller. element.click() follows the link. SwiftUI idiom (as Button): the initializer that takes action: makes the card interactive; the one without it is a plain container. disabled follows .disabled(_:); label sets accessibilityLabel. Content is a @ViewBuilder. Not built on this platform yet: the initializer that takes destination: URL in place of action: draws the same control as a SwiftUI Link, so the address is opened by the environment's openURL — an app that routes addresses itself provides an OpenURLAction, which is how the navigation is cancelled — and VoiceOver reads the link trait. target: TanreiLinkTarget = .same and targetLabel (the accessibility hint) are parameters of that initializer alone. Compose idiom (as Card): the overload with a non-null onClick is interactive; enabled is the inverse flag. Content is a ColumnScope lambda and reads the content color from LocalTanreiContentColor and plain text's style from LocalTanreiTextStyle, Tanrei's own locals (the package does not depend on Material, whose LocalContentColor it would otherwise be). Not built on this platform yet: the overload that takes href: String (onClick is then optional and called first) opens the address through LocalUriHandler.current after onClick; an app that routes addresses itself provides its own UriHandler. Compose has no link role for a clickable element (only link annotations inside text), so the node drops Role.Button and carries no role, and the README says so. target: TanreiLinkTarget = TanreiLinkTarget.Same; targetLabel is appended to the node's state description. Flutter idiom: a non-null onTap makes the card interactive. enabled: false disables it while keeping its interactive look (as ListTile), because a null onTap already means not interactive. Content is child. Not built on this platform yet: the TanreiCard.link constructor (href is a Uri) has Semantics(link: true) with the address as its linkUrl. The package has no plugin dependency, so the address is opened by the nearest TanreiLinkOpener, an inherited widget the app provides once (calling url_launcher, or its own router); without one the control opens nothing and reports it in debug builds. target is a TanreiLinkTarget; targetSemanticLabel is the semantics hint. A non-null onPress makes the card interactive (an accessible Pressable with the button role); without it the card is a View. Content is children. Not built on this platform yet: a Card with href has accessibilityRole="link" and opens the address with Linking.openURL after onPress, unless onPress called event.preventDefault(). target is "same" | "new"; targetLabel is the accessibilityHint.

令牌

令牌 引用 解析值(浅色)
--tanrei-comp-card-container-padding sys.space.space200 16px
--tanrei-comp-card-container-shape sys.shape.corner.medium 8px
--tanrei-comp-card-disabled-opacity — 0.38
--tanrei-comp-card-elevated-container-color sys.color.surface-container-lowest #ffffff
--tanrei-comp-card-elevated-container-elevation sys.elevation.level2 0px 0px 0px 1px rgb(0 0 0 / 0.08), 0px 1px 2px 0px rgb(0 0 0 / 0.04)
--tanrei-comp-card-elevated-content-color sys.color.on-surface #1b1b1b
--tanrei-comp-card-elevated-disabled-container-color — #ffffff
--tanrei-comp-card-elevated-disabled-container-elevation — 0px 0px 0px 1px rgb(0 0 0 / 0.08)
--tanrei-comp-card-elevated-disabled-content-color — #1b1b1b
--tanrei-comp-card-elevated-disabled-outline-color — #000000 · 0%
--tanrei-comp-card-elevated-disabled-state-layer-color — #1b1b1b · 38%
--tanrei-comp-card-elevated-focused-container-color — #e8e8e8
--tanrei-comp-card-elevated-focused-container-elevation — 0px 0px 0px 1px rgb(0 0 0 / 0.08), 0px 1px 2px 0px rgb(0 0 0 / 0.04)
--tanrei-comp-card-elevated-focused-content-color — #1b1b1b
--tanrei-comp-card-elevated-focused-outline-color — #000000 · 0%
--tanrei-comp-card-elevated-focused-state-layer-color — #1b1b1b
--tanrei-comp-card-elevated-hovered-container-color — #ededed
--tanrei-comp-card-elevated-hovered-container-elevation — 0px 0px 0px 1px rgb(0 0 0 / 0.08), 0px 1px 1px 0px rgb(0 0 0 / 0.04), 0px 4px 8px -4px rgb(0 0 0 / 0.04), 0px 16px 24px -8px rgb(0 0 0 / 0.06)
--tanrei-comp-card-elevated-hovered-content-color — #1b1b1b
--tanrei-comp-card-elevated-hovered-outline-color — #000000 · 0%
--tanrei-comp-card-elevated-hovered-state-layer-color — #1b1b1b
--tanrei-comp-card-elevated-outline-color — #000000 · 0%
--tanrei-comp-card-elevated-pressed-container-color — #e8e8e8
--tanrei-comp-card-elevated-pressed-container-elevation — 0px 0px 0px 1px rgb(0 0 0 / 0.08), 0px 1px 2px 0px rgb(0 0 0 / 0.04)
--tanrei-comp-card-elevated-pressed-content-color — #1b1b1b
--tanrei-comp-card-elevated-pressed-outline-color — #000000 · 0%
--tanrei-comp-card-elevated-pressed-state-layer-color — #1b1b1b
--tanrei-comp-card-elevated-state-layer-color comp.card.elevated.content.color #1b1b1b
--tanrei-comp-card-filled-container-color sys.color.surface-container-highest #eeeeee
--tanrei-comp-card-filled-container-elevation sys.elevation.level0
--tanrei-comp-card-filled-content-color sys.color.on-surface #1b1b1b
--tanrei-comp-card-filled-disabled-container-color — #eeeeee
--tanrei-comp-card-filled-disabled-container-elevation —
--tanrei-comp-card-filled-disabled-content-color — #1b1b1b
--tanrei-comp-card-filled-disabled-outline-color — #000000 · 0%
--tanrei-comp-card-filled-disabled-state-layer-color — #1b1b1b · 38%
--tanrei-comp-card-filled-focused-container-color — #d9d9d9
--tanrei-comp-card-filled-focused-container-elevation —
--tanrei-comp-card-filled-focused-content-color — #1b1b1b
--tanrei-comp-card-filled-focused-outline-color — #000000 · 0%
--tanrei-comp-card-filled-focused-state-layer-color — #1b1b1b
--tanrei-comp-card-filled-hovered-container-color — #dddddd
--tanrei-comp-card-filled-hovered-container-elevation —
--tanrei-comp-card-filled-hovered-content-color — #1b1b1b
--tanrei-comp-card-filled-hovered-outline-color — #000000 · 0%
--tanrei-comp-card-filled-hovered-state-layer-color — #1b1b1b
--tanrei-comp-card-filled-outline-color — #000000 · 0%
--tanrei-comp-card-filled-pressed-container-color — #d9d9d9
--tanrei-comp-card-filled-pressed-container-elevation —
--tanrei-comp-card-filled-pressed-content-color — #1b1b1b
--tanrei-comp-card-filled-pressed-outline-color — #000000 · 0%
--tanrei-comp-card-filled-pressed-state-layer-color — #1b1b1b
--tanrei-comp-card-filled-state-layer-color comp.card.filled.content.color #1b1b1b
--tanrei-comp-card-focused-opacity — 1
--tanrei-comp-card-hovered-opacity — 1
--tanrei-comp-card-motion-feedback-duration sys.motion.role.feedback 450ms
--tanrei-comp-card-motion-state-duration sys.motion.role.state.large 250ms
--tanrei-comp-card-motion-state-easing sys.motion.easing.state cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-card-outline-width sys.border.hairline 1px
--tanrei-comp-card-outlined-container-color sys.color.surface #ffffff
--tanrei-comp-card-outlined-container-elevation sys.elevation.level0
--tanrei-comp-card-outlined-content-color sys.color.on-surface #1b1b1b
--tanrei-comp-card-outlined-disabled-container-color — #ffffff
--tanrei-comp-card-outlined-disabled-container-elevation —
--tanrei-comp-card-outlined-disabled-content-color — #1b1b1b
--tanrei-comp-card-outlined-disabled-outline-color — #000000 · 8%
--tanrei-comp-card-outlined-disabled-state-layer-color — #1b1b1b · 38%
--tanrei-comp-card-outlined-focused-container-color — #e8e8e8
--tanrei-comp-card-outlined-focused-container-elevation —
--tanrei-comp-card-outlined-focused-content-color — #1b1b1b
--tanrei-comp-card-outlined-focused-outline-color — #000000 · 8%
--tanrei-comp-card-outlined-focused-state-layer-color — #1b1b1b
--tanrei-comp-card-outlined-hovered-container-color — #ededed
--tanrei-comp-card-outlined-hovered-container-elevation —
--tanrei-comp-card-outlined-hovered-content-color — #1b1b1b
--tanrei-comp-card-outlined-hovered-outline-color — #000000 · 8%
--tanrei-comp-card-outlined-hovered-state-layer-color — #1b1b1b
--tanrei-comp-card-outlined-outline-color sys.color.outline-variant #000000 · 8%
--tanrei-comp-card-outlined-pressed-container-color — #e8e8e8
--tanrei-comp-card-outlined-pressed-container-elevation —
--tanrei-comp-card-outlined-pressed-content-color — #1b1b1b
--tanrei-comp-card-outlined-pressed-outline-color — #000000 · 8%
--tanrei-comp-card-outlined-pressed-state-layer-color — #1b1b1b
--tanrei-comp-card-outlined-state-layer-color comp.card.outlined.content.color #1b1b1b
--tanrei-comp-card-pressed-opacity — 1

测试样例

作为基准的渲染结果。所有平台都以相同的 ID 为每个样例截取快照,以便相互比较。

生成自 specs/card.spec.json, tokens/comp/card.tokens.json, design/tokens/

指南

何时使用

用卡片把关于同一个对象的内容归为一组,并与周围的内容区分开来,例如一份报告、一个人、一件商品或一项设置。卡片只做三件事:给内容留出内边距、按自己的形状裁切内容、为内容提供颜色。内容的布局由你来完成,使用 Tanrei 的文本样式(卡片标题用 heading-5)和组件。

如果整张卡片就是一个操作,用来打开它所展示的内容,就让整张卡片可交互。如果它需要多个操作,就让卡片保持不可交互,把按钮放在里面。

有目标地址的卡片是链接

有目标地址的控件是链接,卡片也不例外。如果激活卡片只是把用户带到另一个页面,就给它 href,而不是在可交互卡片上处理点击。这样卡片就是一个真正的链接,在 Web 上是包住其内容的锚元素。用户可以在新标签页中打开它、复制它的地址,并在前往之前看到它通向哪里;辅助技术朗读的是链接,而不是按钮。本站中从一个页面通向另一个页面的卡片都是链接卡片。

  • 链接卡片在每种状态下的外观都与可交互卡片完全相同。没有链接专属的外观,也没有“已访问”状态。
  • href 优先于 interactive:两者都有的卡片是链接。
  • Enter 会前往链接卡片的目标。Space 不会:它的作用与在任何链接上一样,在 Web 上就是滚动页面。
  • 先触发 click,然后才打开目标。如果要像客户端路由那样自己接管导航,就取消这次点击。
  • target 为 same(默认)或 new。在新标签页或新窗口中打开的链接需要 targetLabel,也就是在名称之后朗读的文字,例如“在新标签页中打开”。Tanrei 不自带任何文案。卡片不会为此绘制标记,因为内容是你的:想显示标记的话,把 open_in_new 图标放进内容里。
  • 禁用的链接卡片无论用什么方式都无法前往目标,也不在焦点顺序之内。
  • 只含空白字符的 href 不算目标地址。javascript: 地址,或无法解析为地址的值,会被拒绝并在调试构建中报告,卡片则表现得如同没有设置它。

如果卡片既需要目标地址,又需要自己的操作,就让它保持普通卡片,把它的标题做成链接,或者在里面放一个带 href 的按钮。

如何选择变体

按背景来选,而不是按重要程度。

变体 靠什么区分 用于
Outlined(默认) 一道边线 大多数卡片,以及放在与 filled 卡片同色调的表面上的卡片。
Filled 色调 放在素净表面上的卡片,仅凭色调就能标出这一组内容。放在同色调的表面上,filled 卡片会消失不见。
Elevated 纵深 位于页面之上的内容,例如固定的项或可拖动的项。

推荐

  • 用 label 为可交互卡片或链接卡片命名,通常取它标题的文字。
  • 一起展示的一组卡片使用同一种变体。
  • 对于要铺到边缘的媒体,把 inset 设为 false(在 Web 上是 flush),并用卡片的内边距令牌自行为下方的文本留出内边距。

避免

  • 不要在可交互卡片或链接卡片里放按钮、链接、输入框、开关或其他任何可聚焦的元素。辅助技术无法到达它,而且在它上面按下也会激活卡片。
  • 不要在可交互卡片的点击处理函数里进行导航。要去往别处的卡片应使用 href。
  • 不要禁用普通卡片。disabled 在那里会被忽略,因为没有可禁用的操作或链接。
  • 不要用层级来给卡片排重要程度。

无障碍

角色
button (link while href is set)
标注
The role is button while interactive is true and link while href is set; otherwise the card has no role, or is a group when label is set. An interactive card's or a link card's name is label — normally its heading's text — or, without one, its whole content read as one string (reported in debug builds). Their content is presentational: its headings and images are read as part of the name, not as separate elements. While target is new, targetLabel is a link card's description.
最小目标
即使外观尺寸更小,点击区域也满足最小目标尺寸。 minimum 32px · pointer 44px · spacing 8px · touch 48px

键盘操作

按键 动作
Tab Moves focus to an interactive card or a link card. A card that is neither is not a tab stop; focus moves through its content.
Enter / Space Activates an interactive card. A link card is activated by Enter alone: Space does what it does on any link (on the web it scrolls the page).

备注

  • Never nest interactive elements in an interactive card or a link card: assistive technology cannot reach them (the card is one element) and a press on them would also activate the card. A card that needs its own buttons or links stays plain, and its primary action or destination is one of them: a heading that is a link, or a link Button.
  • Web: an interactive card draws a shadow element with role button and tabindex 0 around the slot (not a <button>, whose content model forbids block content); Enter activates on keydown and Space on keyup, as a native button does. A link card draws an anchor around the slot instead, which HTML allows around block content that holds nothing interactive. In debug builds either inspects the slotted content on every slotchange for focusable or interactive descendants and reports them.
  • SwiftUI, Compose, Flutter and React Native merge the content into the one accessible element (SwiftUI's button, Compose's merged clickable semantics, Flutter's merged semantics, React Native's accessible Pressable). None of them can reliably detect a nested interactive element, so each states the rule in its README; their tests assert that the merged element is the only one exposed.
  • Text size does not clip a card: it grows with its content at 200% text size.
  • How a state looks is a value decision of the active design system; components read the state's tokens and never compute the effect themselves.
  • A card with a destination is a link: one element with the link role, the platform's own link, Enter and not Space, so that on the web the card can be opened in a new tab and its address copied. No visited state is drawn.
  • A link card that opens a new context says so through targetLabel. The card draws no mark of its own, since it does not lay out its content; a product that shows one puts the glyph open_in_new of the system's icon set in the content, where the icon helper mirrors it in a right-to-left layout.

生成自 specs/card.spec.json, tokens/sys/measure.tokens.json

组件负责的部分

  • 不可交互的卡片没有角色,也不改动其内容的语义。有 label 时,它是以该标签命名的分组。
  • 可交互卡片是一个可聚焦的元素,角色为按钮,并暴露其禁用状态。它的名称是 label;没有 label 时,是它的全部内容连读成的一个字符串。缺少标签会在调试构建中报告。
  • 链接卡片是一个可聚焦的元素,角色为链接,命名方式相同。当 target 为 new 时,targetLabel 是它的描述,在名称和角色之后朗读。禁用的链接卡片保留链接角色,暴露禁用状态,并且没有目标地址。
  • Enter 和 Space 激活可交互卡片,链接卡片只由 Enter 激活。点击区域满足最小目标,焦点指示只在键盘聚焦时显示。
  • 卡片随内容变大,因此大号文本不会被裁切。
  • 在 Web 上,调试构建会在每次插槽内容变化时报告可交互卡片或链接卡片里的可交互元素,也会报告在新标签页中打开却没有 targetLabel 的链接卡片。

已知的平台差距:

  • SwiftUI、Compose、Flutter 和 React Native 还没有链接卡片。在这些平台上,要去往别处的卡片仍然是在其操作中进行导航的可交互卡片。
  • Web:之后才添加到卡片内已有元素更深层的可交互元素不会被报告。
  • Web:不可交互的卡片自己从不产生点击,但在其内容上的点击是普通的 DOM 点击,会经由卡片冒泡。
  • SwiftUI、Compose、Flutter 和 React Native 无法检测嵌套的可交互元素,因此不做这项检查。这条规则要靠你来遵守。
  • React Native:带标签的不可交互卡片是一个分组,但 iOS 不把这个角色映射为任何特征。VoiceOver 是否朗读分组的名称,尚未在真机上验证。
  • Compose:通过平台按键处理由 Enter 和 Space 激活,目前只在 Robolectric 下测试过,尚未在真机上测试。
  • React Native 没有可继承的文本颜色。卡片以自己的内容颜色绘制纯字符串;你自己的文本用 useContentColor() 读取这个颜色。
  • SwiftUI:小于最小目标的可交互卡片保持其可见尺寸,但在布局中占据最小目标的大小。

需要你做的事

  • 为可交互卡片或链接卡片提供 label。没有它,全部内容会被读成一个很长的名称。
  • 为在新标签页或新窗口中打开的链接卡片提供 targetLabel,使用你的产品的语言。
  • 可交互卡片和链接卡片里不要放其他控件。如果卡片需要自己的按钮或链接,就让它保持普通卡片,并让其中一个充当它的主要操作或目标地址。
  • 为不可交互卡片里的标题、图片和其他内容提供各自的语义:替代文本、标题级别。卡片不会添加任何语义。