CardImage | CoUI
본문으로 건너뛰기
LogoCoUI

CardImage

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

목차

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

기본 사용법 (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 가 없는 필드(frameStyle.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(
                  frameStyle: CoreOutlinedContainerStyle(
                    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 가 없는 필드(frameStyle.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(
                frameStyle: CoreOutlinedContainerStyle(
                  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',
    );
  }
}

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';

Props / Parameters#

W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. W? 는 선택 슬롯입니다.

여기에는 위젯 생성자 파라미터만 적습니다. 시각 chrome 은 cardImageStyle 슬롯 안에 있습니다.

속성타입기본값설명
imageW필수대표 썸네일
titleW?null제목 슬롯
subtitleW?null제목 아래 부제
leading W? null 앞쪽 슬롯 (아이콘 등)
trailingW?null뒤쪽 슬롯
onPressed VoidCallback? null 클릭 / 탭 콜백
enabled bool? null 클릭 영역을 강제로 활성/비활성
cardImageStyle CoreCardImageStyle? null chrome / 치수 오버라이드 슬롯. 아래 CoreCardImageStyle 표 참고

CoreCardImageStyle 필드#

필드타입설명
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].
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.

스타일 시스템 (Style System)#

테마#

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

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

동작 스펙 (Behavior)#

  • image 와 텍스트 슬롯을 cardImageStyle.direction 축으로 배치합니다. 이미지는 프레임 안에 클립되고 hover 시 normalScale / hoverScale 사이를 hoverDuration(Duration, ms)으로 전환합니다.
  • onPressed 가 없으면 표시용 콘텐츠입니다. 콜백이 있을 때 enabled ?? true 로 활성 여부를 정하며, 비활성은 onPressed 실행과 키보드 활성화를 막습니다.
  • onPressed 는 탭 · Enter · Space 활성화에서 실행합니다. 이미지 로딩·실패 폴백은 주입한 image 의 책임입니다.

사용 가이드라인 (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)#

역할#

역할은 카드가 눌리는지로 갈리고, 그 판정은 두 플랫폼이 같습니다 — 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 상태는 시각으로만 표현되며 보조 기술에 노출되지 않습니다.

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

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
활성화 표면 Button(variant: fixed) <div role="button"> 의 click / keydown
hover 감지버튼의 WidgetStatesController루트 mouseenter / mouseleave
텍스트 슬롯이 없음Gap과 빈 Basic 도 구성Gap · 텍스트 래퍼를 생략
포커스 표시합성된 버튼의 CoUI 링브라우저 기본 outline

Web은 비활성·표시용 카드에서도 hover 감지가 남고 Flutter는 버튼이 없는 경로에서 확대하지 않습니다. 현재 동작 결손으로 확인된 비대칭이며, 공통 hover 계약이 일치한다고 보장하지 않습니다.

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