首页
/ Material UI Card 组件族实战:从基础卡片、媒体展示到整卡交互与 Active 状态样式

Material UI Card 组件族实战:从基础卡片、媒体展示到整卡交互与 Active 状态样式

2026-09-05 17:36:44作者:傅爽业Veleda

本篇技术指南基于 Material UI 官方 Card 文档(cards.md),系统讲解 Card 及其配套组件 CardContentCardHeaderCardMediaCardActionsCardActionArea 的完整用法。读完本文,你将能够:构建基础/描边卡片、用 Collapse 实现可展开的复杂交互卡片、正确选择 CardMedia 的两种媒体渲染模式、用 CardActionArea 让整张卡片可点击,并用 data-active 属性定制卡片的选中态样式——同时结合 packages/mui-material/src/Card 等源码目录,理解每个组件的渲染机制、默认样式与可定制类名。

Card 组件族总览

在 Material Design 规范中,卡片(Card)是承载"单一主题的内容与操作"的表面。Material UI 用一个主容器加若干配套组件来覆盖各种卡片场景:

  • Card:表面层容器,负责把相关组件分组,基于 Paper 实现;
  • CardContent:卡片正文内容的包装器;
  • CardHeader:可选的头部包装器,支持头像(avatar)、操作(action)、标题(title)、副标题(subheader)等插槽;
  • CardMedia:可选的媒体容器,用于展示图片、视频等;
  • CardActions:可选的按钮组包装器,通常位于卡片底部;
  • CardActionArea:可选的交互区包装器,让用户与卡片的指定区域(通常是整卡)进行交互。

在仓库中,这六个组件分别位于独立源码目录:

官方文档也提醒:虽然卡片可以容纳多个操作、UI 控件和溢出菜单,但应克制使用——卡片的设计初衷是"通向更复杂、更详细信息的入口点"。

基础卡片:Card + CardContent

最小可用的卡片只需 CardCardContent

import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';

官方示例 BasicCard.tsx 展示了完整的"每日单词"卡片结构:

import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';

const bull = (
  <Box
    component="span"
    sx={{ display: 'inline-block', mx: '2px', transform: 'scale(0.8)' }}
  ></Box>
);

export default function BasicCard() {
  return (
    <Card sx={{ minWidth: 275 }}>
      <CardContent>
        <Typography gutterBottom sx={{ color: 'text.secondary', fontSize: 14 }}>
          Word of the Day
        </Typography>
        <Typography variant="h5" component="div">
          be{bull}nev{bull}o{bull}lent
        </Typography>
        <Typography sx={{ color: 'text.secondary', mb: 1.5 }}>adjective</Typography>
        <Typography variant="body2">
          well meaning and kindly.
          <br />
          {'"a benevolent smile"'}
        </Typography>
      </CardContent>
      <CardActions>
        <Button size="small">Learn More</Button>
      </CardActions>
    </Card>
  );
}

结合源码可以确认几个默认行为:

  1. Card 是 Paper 的特化Card.js 中根节点由 styled(Paper) 派生,仅追加一条 overflow: 'hidden' 样式(这也是后续 CardMedia 图片被裁切、焦点环需要改为内缩的原因)。因此 Card 继承 Paper 的 variantelevationsquarecomponent 等全部属性。
  2. raised 属性等价于 elevation 8。从 Card.js 可见,raised={true} 时向 Paper 传入 elevation={8};其 PropTypes 还内置了一条校验:raisedvariant="outlined" 同时使用时会告警"组合无效"。
  3. CardContent 的默认内边距CardContent.js 中根节点固定 padding: 16,且当它是最后一个子元素时 paddingBottom 增大为 24,保证卡片底部留白更舒适。

描边卡片:variant="outlined"

设置 variant="outlined" 即可渲染描边卡片。该属性来自继承自 Paper 的 variant(可选值 elevated(默认)/ outlined / filled)。官方示例 OutlinedCard.tsx 将卡片正文抽成可复用片段:

const card = (
  <React.Fragment>
    <CardContent>
      <Typography gutterBottom sx={{ color: 'text.secondary', fontSize: 14 }}>
        Word of the Day
      </Typography>
      {/* ... 同 BasicCard 的正文 ... */}
    </CardContent>
    <CardActions>
      <Button size="small">Learn More</Button>
    </CardActions>
  </React.Fragment>
);

export default function OutlinedCard() {
  return (
    <Box sx={{ minWidth: 275 }}>
      <Card variant="outlined">{card}</Card>
    </Box>
  );
}

由于 Card 只是 Paper 的薄封装(见 Card.d.tsCardOwnProps extends DistributiveOmit<PaperOwnProps, 'classes'>),所有 Paper 支持的外观定制手段——elevation 调整、sx 覆写、主题中的 components: { MuiCard: { defaultProps } }——对 Card 同样生效。

复杂交互:可展开的食谱卡片

官方示例 RecipeReviewCard.tsx 展示了桌面端卡片的典型进阶形态:点击右下方的展开箭头(chevron),卡片内容区平滑展开显示完整食谱。其结构为 CardHeader(头像 + 标题 + 更多操作)+ CardMedia + CardContent + CardActions + Collapse。核心代码如下:

import { styled } from '@mui/material/styles';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import CardMedia from '@mui/material/CardMedia';
import CardContent from '@mui/material/CardContent';
import CardActions from '@mui/material/CardActions';
import Collapse from '@mui/material/Collapse';
import Avatar from '@mui/material/Avatar';
import IconButton, { IconButtonProps } from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import { red } from '@mui/material/colors';
import FavoriteIcon from '@mui/icons-material/Favorite';
import ShareIcon from '@mui/icons-material/Share';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import MoreVertIcon from '@mui/icons-material/MoreVert';

// 通过 styled 让展开箭头随状态旋转 180 度
interface ExpandMoreProps extends IconButtonProps {
  expand: boolean;
}

const ExpandMore = styled((props: ExpandMoreProps) => {
  const { expand, ...other } = props;
  return <IconButton {...other} />;
})(({ theme }) => ({
  marginLeft: 'auto',
  transition: theme.transitions.create('transform', {
    duration: theme.transitions.duration.shortest,
  }),
  variants: [
    {
      props: ({ expand }) => !expand,
      style: { transform: 'rotate(0deg)' },
    },
    {
      props: ({ expand }) => !!expand,
      style: { transform: 'rotate(180deg)' },
    },
  ],
}));

export default function RecipeReviewCard() {
  const [expanded, setExpanded] = React.useState(false);

  const handleExpandClick = () => {
    setExpanded(!expanded);
  };

  return (
    <Card sx={{ maxWidth: 345 }}>
      <CardHeader
        avatar={
          <Avatar sx={{ bgcolor: red[500] }} aria-label="recipe">
            R
          </Avatar>
        }
        action={
          <IconButton aria-label="settings">
            <MoreVertIcon />
          </IconButton>
        }
        title="Shrimp and Chorizo Paella"
        subheader="September 14, 2016"
      />
      <CardMedia
        component="img"
        height="194"
        image="/static/images/cards/paella.jpg"
        alt="Paella dish"
      />
      <CardContent>
        <Typography variant="body2" sx={{ color: 'text.secondary' }}>
          This impressive paella is a perfect party dish ...
        </Typography>
      </CardContent>
      <CardActions disableSpacing>
        <IconButton aria-label="add to favorites">
          <FavoriteIcon />
        </IconButton>
        <IconButton aria-label="share">
          <ShareIcon />
        </IconButton>
        <ExpandMore
          expand={expanded}
          onClick={handleExpandClick}
          aria-expanded={expanded}
          aria-label="show more"
        >
          <ExpandMoreIcon />
        </ExpandMore>
      </CardActions>
      <Collapse in={expanded} timeout="auto" unmountOnExit>
        <CardContent>
          <Typography sx={{ marginBottom: 2 }}>Method:</Typography>
          <Typography sx={{ marginBottom: 2 }}>
            Heat 1/2 cup of the broth in a pot until simmering ...
          </Typography>
          {/* 完整食谱步骤见 demo 源文件 */}
        </CardContent>
      </Collapse>
    </Card>
  );
}

这里有几个可复用的交互技巧:

  • ExpandMorestyled(IconButton) 做条件旋转:箭头是否展开通过 expand 布尔 prop 传入,用 variants 声明 rotate(0deg) / rotate(180deg) 两态,过渡时长取自 theme.transitions.duration.shortest
  • Collapse in={expanded} timeout="auto" unmountOnExit:展开动画时长由组件自动计算,动画结束后从 DOM 卸载,避免隐藏内容仍占用布局与可访问性树;
  • CardActions disableSpacingdisableSpacing 关闭卡片底部按钮组默认的 8px 内边距与控件间距,让图标按钮更紧凑。这一点可以从 CardActions.js 得到印证:根节点默认 display: flex; align-items: center; padding: 8,且只有当 disableSpacingfalse 时才应用 spacing 类(给后续兄弟元素加 margin-left: 8);
  • CardHeader 的插槽化结构:从 cardHeaderClasses.ts 可见,CardHeader 暴露 rootavataractioncontenttitlesubheader 六个样式槽,分别对应示例中的 avataractiontitlesubheader 属性,需要精细定制头部时可按这些类名覆写。

媒体展示:CardMedia 的两种渲染模式

MediaCard.tsx 演示了用图片强化卡片内容的标准用法:

export default function MediaCard() {
  return (
    <Card sx={{ maxWidth: 345 }}>
      <CardMedia
        sx={{ height: 140 }}
        image="/static/images/cards/contemplative-reptile.jpg"
        title="green iguana"
      />
      <CardContent>
        <Typography gutterBottom variant="h5" component="div">
          Lizard
        </Typography>
        <Typography variant="body2" sx={{ color: 'text.secondary' }}>
          Lizards are a widespread group of squamate reptiles, with over 6,000
          species, ranging across all continents except Antarctica
        </Typography>
      </CardContent>
      <CardActions>
        <Button size="small">Share</Button>
        <Button size="small">Learn More</Button>
      </CardActions>
    </Card>
  );
}

默认模式:<div> + 背景图。CardMedia.js 可以看到,CardMedia 根节点默认是 div,样式为 display: block; background-size: cover; background-repeat: no-repeat; background-position: center。当传入 imagecomponent 仍是 div 时,源码会把 image 拼进内联 backgroundImage: url(...),同时给根节点加上 role="img" 以提升可访问性(CardMedia.js)。

局限与替代:component 属性。 背景图方案在某些场景并不合适——比如需要显示视频、或需要响应式 <img>。此时应使用 component 属性让 CardMedia 渲染真实媒体元素,官方示例 ImgMediaCard.tsx

<Card sx={{ maxWidth: 345 }}>
  <CardMedia
    component="img"
    alt="green iguana"
    height="140"
    image="/static/images/cards/contemplative-reptile.jpg"
  />
  {/* CardContent / CardActions 同上 */}
</Card>

源码中的判定逻辑值得注意(CardMedia.js):

  • MEDIA_COMPONENTS = ['video', 'audio', 'picture', 'iframe', 'img']:当 component 是这些媒体元素之一时(isMediaComponent),image 会被转换为真实的 src 属性,并附带 width: 100%
  • IMAGE_COMPONENTS = ['picture', 'img']:当 componentpicture/img 时(isImageComponent),追加 object-fit: cover,与背景图模式保持相同的裁切观感;
  • PropTypes 校验要求 childrenimagesrccomponent 至少提供一个,否则会抛出 "Either children, image, src or component prop must be specified" 错误;
  • 文档特别提示:背景图模式下调用方必须显式指定 height(如 sx={{ height: 140 }}),否则图片不可见——因为 div 没有固有高度。这也是 MediaCard 示例中 height 写在 sx 里的原因。

主要操作:CardActionArea 让整卡可交互

卡片经常需要让用户点击"整张卡片表面"来触发主操作(展开、跳转详情等)。用 CardActionArea 包裹内容即可实现,官方示例 ActionAreaCard.tsx

import CardActionArea from '@mui/material/CardActionArea';
// 其余导入同 MediaCard

export default function ActionAreaCard() {
  return (
    <Card sx={{ maxWidth: 345 }}>
      <CardActionArea>
        <CardMedia
          component="img"
          height="140"
          image="/static/images/cards/contemplative-reptile.jpg"
          alt="green iguana"
        />
        <CardContent>
          <Typography gutterBottom variant="h5" component="div">
            Lizard
          </Typography>
          <Typography variant="body2" sx={{ color: 'text.secondary' }}>
            Lizards are a widespread group of squamate reptiles ...
          </Typography>
        </CardContent>
      </CardActionArea>
    </Card>
  );
}

CardActionArea.js 可以确认它的实现细节:

  • 根节点基于 ButtonBase,因此自带 onClick、键盘可达、focusVisible 等完整的按钮语义,而不仅仅是"可点击的 div";display: blockwidth: 100%border-radius: inherit 让它铺满 Card 内部并继承 Card 的圆角(后者还附带注释指出是为修复 Safari 下的圆角继承问题)。
  • 悬浮反馈通过独立覆盖层实现CardActionArea 内部渲染一个 FocusHighlight 槽(span,绝对定位铺满、pointer-events: nonebackground-color: currentcolor)。鼠标悬停时该层 opacity 取 theme.palette.action.hoverOpacity,键盘聚焦(focus visible)时取 palette.action.focusOpacity,透明度变化使用 theme.transitions.duration.short 过渡;在 @media (hover: none)(触屏设备)下悬浮层透明度固定为 0。
  • 焦点环被设计为内缩(inset):源码注释明确写道 "Card sets overflow:hidden, which clips an outset ring"——因为 Card 根节点 overflow: hidden 会裁掉外扩的焦点环,所以启用 theme.focusVisible 时焦点环向内缩进 1px,避免视觉被截断。

补充操作要与主操作区分离。 卡片还可以提供与主操作并列的补充动作,这些动作必须放在 CardActionArea 之外,以避免事件冒泡重叠。官方示例 MultiActionAreaCard.tsx 的结构是:

<Card sx={{ maxWidth: 345 }}>
  <CardActionArea>
    <CardMedia component="img" height="140" image="..." alt="green iguana" />
    <CardContent>...</CardContent>
  </CardActionArea>
  {/* 补充操作放在 CardActionArea 之外,避免与主点击区域事件重叠 */}
  <CardActions>
    <Button size="small" color="primary">
      Share
    </Button>
  </CardActions>
</Card>

UI 控件:底部媒体控制卡片

补充操作在卡片中通常以图标、文字和 UI 控件形式明确给出,并常规放置在卡片底部。MediaControlCard.tsx 展示了"音乐播放器"式卡片:左侧标题 + 控制按钮纵向排列,右侧是专辑封面。

const theme = useTheme();

<Card sx={{ display: 'flex' }}>
  <Box sx={{ display: 'flex', flexDirection: 'column' }}>
    <CardContent sx={{ flex: '1 0 auto' }}>
      <Typography component="div" variant="h5">
        Live From Space
      </Typography>
      <Typography
        variant="subtitle1"
        component="div"
        sx={{ color: 'text.secondary' }}
      >
        Mac Miller
      </Typography>
    </CardContent>
    <Box sx={{ display: 'flex', alignItems: 'center', pl: 1, pb: 1 }}>
      <IconButton aria-label="previous">
        {theme.direction === 'rtl' ? <SkipNextIcon /> : <SkipPreviousIcon />}
      </IconButton>
      <IconButton aria-label="play/pause">
        <PlayArrowIcon sx={{ height: 38, width: 38 }} />
      </IconButton>
      <IconButton aria-label="next">
        {theme.direction === 'rtl' ? <SkipPreviousIcon /> : <SkipNextIcon />}
      </IconButton>
    </Box>
  </Box>
  <CardMedia
    component="img"
    sx={{ width: 151 }}
    image="/static/images/cards/live-from-space.jpg"
    alt="Live from space album cover"
  />
</Card>

这个示例体现了两个要点:

  1. Card 根节点可以直接用 sx 变成 flex 布局容器,配合 CardContentflex: '1 0 auto' 让标题区撑满剩余高度,底部控制条自然沉底;
  2. RTL 适配:上一首/下一首图标依据 theme.direction 互换,这是 Material UI 对右到左语言的内置约定,控件类卡片应当遵循。

另外可以回顾 CardActions.js 的默认布局:display: flex + align-items: center + padding: 8,未禁用 spacing 时控件之间自动保持 8px 间距——因此底部放两个 size="small"Button(如 MediaCard.tsx 中的 Share / Learn More)即可获得符合规范的间距。

Active 状态样式:data-active 属性 + &[data-active] 选择器

当卡片承担"选中项"语义(如选择列表)时,需要为 CardActionArea 定制激活态。官方推荐做法是:在 CardActionArea 上挂 data-active 属性,再用 &[data-active] 选择器应用样式。官方示例 SelectActionCard.tsx

function SelectActionCard() {
  const [selectedCard, setSelectedCard] = React.useState(0);
  return (
    <Box
      sx={{
        width: '100%',
        display: 'grid',
        gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 100%), 1fr))',
        gap: 2,
      }}
    >
      {cards.map((card, index) => (
        <Card key={card.id}>
          <CardActionArea
            onClick={() => setSelectedCard(index)}
            data-active={selectedCard === index ? '' : undefined}
            sx={{
              height: '100%',
              '&[data-active]': {
                backgroundColor: 'action.selected',
                '&:hover': {
                  backgroundColor: 'action.selectedHover',
                },
              },
            }}
          >
            <CardContent sx={{ height: '100%' }}>
              <Typography variant="h5" component="div">
                {card.title}
              </Typography>
              <Typography variant="body2" sx={{ color: 'text.secondary' }}>
                {card.description}
              </Typography>
            </CardContent>
          </CardActionArea>
        </Card>
      ))}
    </Box>
  );
}

模式拆解:

  • data-active={selectedCard === index ? '' : undefined}:选中时属性存在(空字符串),未选中时 undefined 使属性从 DOM 上移除——属性"存在与否"本身就是状态载体;
  • sx 中的 '&[data-active]' 只对当前根元素带该属性时生效,选中态背景取主题 token action.selected,叠加 &:hover 后变为 action.selectedHover,与 Material 的选中语义色保持一致;
  • 之所以可行,是因为 CardActionArea 根节点基于 ButtonBase,会把未知属性透传到原生元素上(见 CardActionArea.js...other 的展开),sx 也经由同一根节点生效。

源码纵深:类名、插槽与多态能力

综合前面各组件源码,Card 家族的自定义点可以归纳如下,便于按槽位覆写样式:

组件 样式槽(utility classes) 依据
Card root cardClasses.ts
CardActionArea rootfocusHighlight CardActionArea.js
CardActions rootspacing CardActions.js
CardContent root CardContent.js
CardHeader rootavataractioncontenttitlesubheader cardHeaderClasses.ts
CardMedia rootmediaimg(后两者随 component 自动附加) CardMedia.js

此外有两个源码级事实值得了解:

  1. Card 是多态组件(polymorphic)Card.spec.tsx 的类型测试验证了 Card component="a" href="test"Card component={CustomComponent} 等用法下事件与自定义 prop 的类型推导——Card 透传 Paper 的多态能力,可以把整张卡片渲染成链接或任意自定义元素。
  2. 默认 prop 可通过 DefaultPropsProvider 覆盖。所有组件内部都调用了 useDefaultProps({ name: 'MuiCard' })(如 Card.js),意味着你可以在应用层用 MuiCard / MuiCardActionArea 等名字批量设置默认属性,而无需逐个传参。

小结

Material UI 的 Card 家族用"一个 Paper 容器 + 五个职责单一的配套组件"覆盖了卡片场景:CardContent/CardHeader 组织信息,CardMedia 负责媒体(注意 div 背景图与 component="img" 两种模式的取舍及 height 要求),CardActions 承载底部控件,CardActionArea 提供整卡交互与可访问的按钮语义,而 data-active + &[data-active] 则为选中态样式提供了标准化的扩展点。所有示例可直接对照 docs/data/material/components/cards 目录下的 demo 源文件(BasicCard.tsxRecipeReviewCard.tsxMediaCard.tsxImgMediaCard.tsxActionAreaCard.tsxMultiActionAreaCard.tsxMediaControlCard.tsxSelectActionCard.tsx)与 packages/mui-material/src/Card 等源码目录进一步核对细节。

登录后查看全文
热门项目推荐
相关项目推荐