CardImage#
CardImage는 이미지 썸네일과 텍스트 슬롯(제목, 부제, leading, trailing)을
클릭 가능한 카드로 묶습니다. 호버하면 이미지가 normalScale에서
hoverScale로 커지고, direction으로 세로(이미지 위) / 가로(이미지 옆)
레이아웃을 고릅니다.
Live Preview#
class CardImageDefaultExample extends StatelessComponent {
const CardImageDefaultExample({super.key});
@override
Component build(BuildContext context) {
return div(
[
CardImage(
image: img(
src: 'https://picsum.photos/seed/coui/240/160',
classes: 'block object-cover',
styles: Styles(
raw: const {
'width': '240px',
'height': '160px',
'margin': '0',
},
),
),
title: Text('Mountain View'),
subtitle: Text('Captured at sunrise'),
onPressed: () {},
),
],
styles: Styles(raw: const {'width': '240px'}),
);
}
}
class CardImageDefaultExample extends StatelessWidget {
const CardImageDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 240,
child: CardImage(
image: Image.network(
'https://picsum.photos/seed/coui/240/160',
width: 240,
height: 160,
fit: BoxFit.cover,
),
title: const Text('Mountain View'),
subtitle: const Text('Captured at sunrise'),
onPressed: () {},
),
);
}
}
class CardImageChainExample extends StatelessComponent {
const CardImageChainExample({super.key});
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
div(
[
CardImage(
image: img(
src: 'https://picsum.photos/seed/coui/240/160',
classes: 'block object-cover',
styles: Styles(
raw: const {
'width': '240px',
'height': '160px',
'margin': '0',
},
),
),
title: Text('Mountain View'),
subtitle: Text('Captured at sunrise'),
onPressed: () {},
).radius16.primary,
],
styles: Styles(raw: const {'width': '240px'}),
),
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderColor)까지 한 번에.
div(
[
CardImage(
image: img(
src: 'https://picsum.photos/seed/coui/240/160',
classes: 'block object-cover',
styles: Styles(
raw: const {
'width': '240px',
'height': '160px',
'margin': '0',
},
),
),
title: Text('Mountain View'),
subtitle: Text('Captured at sunrise'),
onPressed: () {},
).withStyle(
const CoreCardImageStyle(
backgroundColor: CoreColor.token(CoreColors.tertiaryContainer),
borderColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
),
),
],
styles: Styles(raw: const {'width': '240px'}),
),
],
classes: 'flex flex-col items-start',
);
}
}
class CardImageChainExample extends StatelessWidget {
const CardImageChainExample({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
SizedBox(
width: 240,
child: CardImage(
image: Image.network(
'https://picsum.photos/seed/coui/240/160',
width: 240,
height: 160,
fit: BoxFit.cover,
),
title: const Text('Mountain View'),
subtitle: const Text('Captured at sunrise'),
onPressed: () {},
).radius16.primary,
),
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderColor)까지 한 번에.
SizedBox(
width: 240,
child:
CardImage(
image: Image.network(
'https://picsum.photos/seed/coui/240/160',
width: 240,
height: 160,
fit: BoxFit.cover,
),
title: const Text('Mountain View'),
subtitle: const Text('Captured at sunrise'),
onPressed: () {},
).withStyle(
const CoreCardImageStyle(
backgroundColor: CoreColor.token(CoreColors.tertiaryContainer),
borderColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
),
),
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 갤러리·상품·기사처럼 썸네일 + 제목/부제를 한 카드로 보여줄 때
- 호버 시 이미지가 살짝 커지는 클릭 가능한 블록이 필요할 때
- 이미지와 텍스트를 세로 또는 가로로 나란히 둘 때 (
direction)
대신 다른 컴포넌트를 사용하세요:
Card: media / header / body / footer 자유 슬롯이 필요할 때Avatar: 얼굴·이니셜만 보여줄 때DescriptionCard: 라벨/값 메타데이터를 나열할 때
Import#
import 'package:coui_flutter/coui_flutter.dart';
import 'package:coui_web/coui_web.dart';
import 'package:jaspr/jaspr.dart';
기본 사용법 (Basic Usage)#
CardImage(
image: Image.network('https://picsum.photos/240/160'),
title: const Text('Mountain View'),
subtitle: const Text('Captured at sunrise'),
onPressed: () {},
)
빠른 오버라이드 (Chain)#
이미 만든 CardImage 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 ==
CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class CardImageChainExample extends StatelessWidget {
const CardImageChainExample({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
SizedBox(
width: 240,
child: CardImage(
image: Image.network(
'https://picsum.photos/seed/coui/240/160',
width: 240,
height: 160,
fit: BoxFit.cover,
),
title: const Text('Mountain View'),
subtitle: const Text('Captured at sunrise'),
onPressed: () {},
).radius16.primary,
),
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderColor)까지 한 번에.
SizedBox(
width: 240,
child:
CardImage(
image: Image.network(
'https://picsum.photos/seed/coui/240/160',
width: 240,
height: 160,
fit: BoxFit.cover,
),
title: const Text('Mountain View'),
subtitle: const Text('Captured at sunrise'),
onPressed: () {},
).withStyle(
const CoreCardImageStyle(
backgroundColor: CoreColor.token(CoreColors.tertiaryContainer),
borderColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
),
),
),
],
);
}
}
class CardImageChainExample extends StatelessComponent {
const CardImageChainExample({super.key});
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
div(
[
CardImage(
image: img(
src: 'https://picsum.photos/seed/coui/240/160',
classes: 'block object-cover',
styles: Styles(
raw: const {
'width': '240px',
'height': '160px',
'margin': '0',
},
),
),
title: Text('Mountain View'),
subtitle: Text('Captured at sunrise'),
onPressed: () {},
).radius16.primary,
],
styles: Styles(raw: const {'width': '240px'}),
),
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderColor)까지 한 번에.
div(
[
CardImage(
image: img(
src: 'https://picsum.photos/seed/coui/240/160',
classes: 'block object-cover',
styles: Styles(
raw: const {
'width': '240px',
'height': '160px',
'margin': '0',
},
),
),
title: Text('Mountain View'),
subtitle: Text('Captured at sunrise'),
onPressed: () {},
).withStyle(
const CoreCardImageStyle(
backgroundColor: CoreColor.token(CoreColors.tertiaryContainer),
borderColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
),
),
],
styles: Styles(raw: const {'width': '240px'}),
),
],
classes: 'flex flex-col items-start',
);
}
}
Props / Parameters#
여기에는 위젯 생성자 파라미터만 적습니다. 시각 chrome 은 cardImageStyle
슬롯 안에 있습니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
image |
Widget / Component (필수) |
— | 대표 썸네일 |
title |
Widget? / Component? |
null |
제목 슬롯 |
subtitle |
Widget? / Component? |
null |
제목 아래 부제 |
leading |
Widget? / Component? |
null |
앞쪽 슬롯 (아이콘 등) |
trailing |
Widget? / Component? |
null |
뒤쪽 슬롯 |
onPressed |
VoidCallback? |
null |
클릭 / 탭 콜백 |
enabled |
bool? |
null |
클릭 영역을 강제로 활성/비활성 |
cardImageStyle |
CoreCardImageStyle? |
null |
chrome / 치수 오버라이드 슬롯. 아래 CoreCardImageStyle 표 참고 |
CoreCardImageStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
backgroundColor |
CoreColor? |
Deprecated — image container background colour override. Use [frameStyle]'s
backgroundColor
instead.
|
borderColor |
CoreColor? |
Deprecated — image container border colour override. Use [frameStyle]'s
borderColor
instead.
|
borderRadius |
CoreBorderRadius? |
Deprecated — image frame border radius override. Use [frameStyle]'s
borderRadius
instead.
|
frameStyle |
CoreOutlinedContainerStyle? |
Nested [CoreOutlinedContainerStyle] slot for the image frame — raw-forwarded to the composed
OutlinedContainer(containerStyle: …)
by the platform resolvers. Per-instance override is merged on top of [defaultFrameStyle] (which itself absorbs the deprecated [backgroundColor] / [borderColor] / [borderRadius] fields, so an old caller's override still reaches the frame beneath this slot).
|
imageContentGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the image → text spacer. Forwarded straight to
Gap(gapStyle: …)
by the platform resolvers. Per-instance override is merged on top of [defaultImageContentGapStyle].
|
hoverScale |
double? |
Image scale factor when the card is hovered. |
normalScale |
double? |
Image scale factor when the card is idle. |
hoverDuration |
Duration? |
Hover scale transition duration override. null defers to [defaultHoverDuration]. |
direction |
CoreAxis? |
Layout direction of the image relative to the text slot. |
사용 가이드라인 (Usage Guidelines)#
✅ Do#
클릭 가능한 카드로 만들려면 onPressed를 넘기기
CardImage(
image: thumbnail,
title: const Text('Mountain View'),
onPressed: handleTap,
)
enabled는 onPressed != null로 자동 유도되는 nullable 값(enabled ?? (onPressed != null))입니다 — onPressed가 있어야 카드가 실제로 버튼처럼(role/tabindex/키보드 활성화) 렌더됩니다.
❌ Don't#
onPressed 없이 enabled: false만으로 "비활성 카드"를 표현하지 않기
// ❌ onPressed 가 없는데 enabled: false 로 비활성 표시를 기대
CardImage(
image: thumbnail,
title: const Text('Archived'),
enabled: false,
)
onPressed가 없으면 카드는 애초에 버튼으로 감싸이지 않고 평범한 콘텐츠로 렌더됩니다 — role/tabindex/포커스 링이 처음부터 없으므로 enabled: false를 추가해도 시각적으로나 접근성 측면에서나 아무 차이가 없습니다. "비활성 카드"가 필요하면 onPressed는 유지한 채 enabled: false를 주세요.
접근성 (Accessibility)#
역할 (Semantics)#
역할은 카드가 눌리는지로 갈리고, 그 판정은 두 플랫폼이 같습니다 — onPressed 가 있고 enabled
가 그것을 끄지 않았을 때만 컨트롤입니다 (enabled ?? (onPressed != null)).
Flutter 는 onPressed 가 있을 때만 카드 전체를 Button(variant: .fixed) 으로 감쌉니다. 그러면 Clickable
의 Semantics(button: true, enabled: …) 가 올라가 카드 한 장이 버튼 하나로 노출됩니다. onPressed
가 없으면 Button 없이 콘텐츠를 그대로 반환하므로 평범한 콘텐츠로 읽힙니다.
Web 루트 <div> 는 세 갈래입니다.
| 조건 | 루트에 emit 되는 것 |
|---|---|
onPressed 있음 · 활성 |
role="button" · tabindex="0" |
onPressed 있음 · enabled: false |
role="button" · aria-disabled="true" (tab stop 없음) |
onPressed 없음 | 없음 — caller 가 attributes 로 넣은 것만 DOM 에 도달 |
눌리는 카드에서는 컴포넌트가 붙이는 role / tabindex 가 caller attributes 뒤에 병합되므로, caller 가 이 둘을 다른 값으로 바꿀 수는 없습니다.
키보드#
| 키 | 플랫폼 | 동작 |
|---|---|---|
Enter / Space |
Flutter | 카드 활성화 (Clickable 의 activation shortcut → ActivateIntent) |
Enter / Space |
Web | 루트 keydown 핸들러가 기본 동작을 막고 onPressed 호출 |
두 경로 모두 눌리는 카드에만 붙습니다. enabled: false 이거나 onPressed 가 없으면 어느 쪽에도 키보드 활성화가 없습니다.
포커스#
Flutter 는 눌리는 카드가 포커스를 받고 Button 이 FocusOutline 링을 그립니다. enabled: false
면 FocusableActionDetector 가 꺼져 탭 순서에서 빠지고, onPressed 가 없으면 애초에 포커스 대상이 아닙니다.
Web 은 tabindex="0" 으로 탭 순서에 들어가지만 CoUI 포커스 링을 그리지 않습니다 — 루트 className 은 flex · w-fit
· select-none 과 (눌릴 때) cursor-pointer 뿐이라 포커스 표시는 브라우저 기본 outline 에 맡겨집니다.
aria-disabled 상태에는 tabindex 가 붙지 않아 탭 순서 밖입니다.
스크린 리더#
눌리는 카드는 양쪽 모두 버튼으로 읽히지만, 이름이 붙는 방식이 다릅니다. Web 은 role="button" 요소의 내용에서 이름을 계산하므로
leading / title / subtitle / trailing 슬롯의 텍스트가 그대로 버튼 이름이 됩니다. Flutter 쪽
Semantics 는 label 을 설정하지 않고 자식 노드를 병합하지도 않으므로, 버튼 노드 자체에는 이름이 없고 슬롯 텍스트는 그 아래 별개 노드로 남습니다. 두 플랫폼 어디에도 레이블을 받는 파라미터는 없습니다.
enabled: false 인 카드는 Flutter Semantics(enabled: false) · Web aria-disabled="true"
로 지금은 쓸 수 없는 버튼임이 안내됩니다. onPressed 없는 카드는 양쪽 모두 이름 없는 일반 콘텐츠이고, 슬롯 텍스트는 컨트롤 의미 없는 평범한 텍스트로 읽힙니다.
image 슬롯은 두 플랫폼 모두 caller 가 넣은 이미지 위젯/컴포넌트가 스스로 알리는 내용만 기여합니다 — CardImage 는 대체 텍스트나 이미지 레이블을 따로 제공하지 않습니다.
알려진 제약#
-
Flutter 버튼 노드에는 이름이 없습니다.
Semantics가label도MergeSemantics도 쓰지 않아, 스크린 리더는 이름 없는 버튼을 먼저 읽고 슬롯 텍스트를 뒤이어 읽습니다. Web 은 내용에서 이름을 계산하므로 같은 카드가 두 플랫폼에서 다르게 불립니다. -
포커스 표시가 두 플랫폼에서 다릅니다. Flutter 는
FocusOutline링을, Web 은 브라우저 기본 outline 을 씁니다. Web 쪽 포커스 링을 토큰으로 맞출 style 슬롯은 없습니다. - 두 플랫폼 모두
image에 대체 텍스트 경로가 없습니다 — 이미지가 정보를 담는다면 caller 가 이미지 자체에 부여해야 합니다. -
Web 의
aria-disabled="true"는 안내 전용입니다. 요소가inert가 되지는 않으므로, caller 가onClick을 직접 넘겼다면 비활성 상태에서도 그 핸들러는 실행됩니다 (onPressed는 호출되지 않습니다). -
hover 확대가 활성 여부와 어긋납니다. Web 은
mouseenter/mouseleave를 항상 등록하므로aria-disabled카드도 hover 하면 이미지가 커집니다 (cursor-pointer는 붙지 않습니다). Flutter 는WidgetStatesController가Button에만 연결되어, 눌리지 않는 카드는 hover 확대가 아예 없습니다. - hover / press 상태는 시각으로만 표현되며 보조 기술에 노출되지 않습니다.
모션 감소 · 고대비 · 최소 터치 타깃처럼 모든 컴포넌트에 공통으로 적용되는 축은 전역 접근성 축 에 정리되어 있습니다.
관련 컴포넌트 (Related Components)#
- Card: media / header / body / footer 자유 슬롯이 필요할 때
- DescriptionCard: 라벨/값 메타데이터를 나열할 때
- Avatar: 얼굴·이니셜만 보여줄 때
테마#
기본값은 CoreCardImageTheme으로 덮어씁니다.
const CoreComponentTheme(
cardImage: CoreCardImageTheme(
style: CoreCardImageStyle(
direction: CoreAxis.horizontal,
hoverScale: 1.1,
imageContentGapStyle: CoreGapStyle(size: 16),
),
),
);