CardImage | CoUI
LogoCoUI

CardImage

이미지 썸네일과 제목/부제 슬롯을 묶는 클릭 가능한 카드

CardImage#

CardImage는 이미지 썸네일과 텍스트 슬롯(제목, 부제, leading, trailing)을 클릭 가능한 카드로 묶습니다. 호버하면 이미지가 normalScale에서 hoverScale로 커지고, direction으로 세로(이미지 위) / 가로(이미지 옆) 레이아웃을 고릅니다.

Live Preview#

사용 시기 (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,
)

enabledonPressed != 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) 으로 감쌉니다. 그러면 ClickableSemantics(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 는 눌리는 카드가 포커스를 받고 ButtonFocusOutline 링을 그립니다. enabled: falseFocusableActionDetector 가 꺼져 탭 순서에서 빠지고, 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 쪽 Semanticslabel 을 설정하지 않고 자식 노드를 병합하지도 않으므로, 버튼 노드 자체에는 이름이 없고 슬롯 텍스트는 그 아래 별개 노드로 남습니다. 두 플랫폼 어디에도 레이블을 받는 파라미터는 없습니다.

enabled: false 인 카드는 Flutter Semantics(enabled: false) · Web aria-disabled="true" 로 지금은 쓸 수 없는 버튼임이 안내됩니다. onPressed 없는 카드는 양쪽 모두 이름 없는 일반 콘텐츠이고, 슬롯 텍스트는 컨트롤 의미 없는 평범한 텍스트로 읽힙니다.

image 슬롯은 두 플랫폼 모두 caller 가 넣은 이미지 위젯/컴포넌트가 스스로 알리는 내용만 기여합니다 — CardImage 는 대체 텍스트나 이미지 레이블을 따로 제공하지 않습니다.

알려진 제약#

  • Flutter 버튼 노드에는 이름이 없습니다. SemanticslabelMergeSemantics 도 쓰지 않아, 스크린 리더는 이름 없는 버튼을 먼저 읽고 슬롯 텍스트를 뒤이어 읽습니다. 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 는 WidgetStatesControllerButton 에만 연결되어, 눌리지 않는 카드는 hover 확대가 아예 없습니다.
  • hover / press 상태는 시각으로만 표현되며 보조 기술에 노출되지 않습니다.

모션 감소 · 고대비 · 최소 터치 타깃처럼 모든 컴포넌트에 공통으로 적용되는 축은 전역 접근성 축 에 정리되어 있습니다.

  • Card: media / header / body / footer 자유 슬롯이 필요할 때
  • DescriptionCard: 라벨/값 메타데이터를 나열할 때
  • Avatar: 얼굴·이니셜만 보여줄 때

테마#

기본값은 CoreCardImageTheme으로 덮어씁니다.

const CoreComponentTheme(
  cardImage: CoreCardImageTheme(
    style: CoreCardImageStyle(
      direction: CoreAxis.horizontal,
      hoverScale: 1.1,
      imageContentGapStyle: CoreGapStyle(size: 16),
    ),
  ),
);