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

HoverCard

트리거 요소에 호버 시 추가 정보를 표시하는 카드 컴포넌트

목차

트리거 요소 위에 마우스를 올리면 추가 정보를 담은 카드를 표시하는 컴포넌트입니다. 지연 시간을 조절하여 의도치 않은 표시를 방지할 수 있습니다.

기본 사용법 (Basic Usage)#

// 기본 호버 카드
HoverCard(
  trigger: const Text('@nextjs').bodyMedium.onPrimaryContainer,
  content: const Text('The React Framework for the Web.').bodyMedium.onSurface,
)

// 표시 위치 및 지연 시간 설정
HoverCard(
  trigger: const Icon(LucideIcons.info),
  content: const Text('추가 정보를 여기에 표시합니다.').bodyMedium.onSurface,
  placement: CorePopoverPlacement.top,
  openDelay: const Duration(milliseconds: CoreDuration.slow),
  closeDelay: const Duration(milliseconds: CoreDuration.normal),
)
HoverCard(
  trigger: Text('@nextjs').bodyMedium.onPrimaryContainer,
  content: Text('The React Framework for the Web.').bodyMedium.onSurface,
)

HoverCard(
  trigger: const Icon(LucideIcons.info),
  content: Text('추가 정보를 여기에 표시합니다.').bodyMedium.onSurface,
  placement: CorePopoverPlacement.top,
  openDelay: const Duration(milliseconds: CoreDuration.slow),
  closeDelay: const Duration(milliseconds: CoreDuration.normal),
)

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 사용자 멘션(@username), 링크 등에 호버 시 미리보기 카드를 표시할 때
  • 아이콘이나 축약된 텍스트에 호버 시 상세 정보를 보여줄 때
  • 클릭 없이 추가 맥락 정보를 제공하고 싶을 때

대신 다른 컴포넌트를 사용하세요:

  • Tooltip: rich 콘텐츠·액션 없이 짧은 도움말 문자열만 표시할 때
  • Popover: 호버 타이밍 대신 클릭·명령형 제어로 패널을 열어야 할 때
  • 터치 기기에서는 트리거를 길게 눌러 카드를 열 수 있으나(long-press), 마우스 호버 환경이 주 사용처이므로 중요 정보는 다른 방법으로도 접근 가능하게 한다

Props / Parameters#

W 는 Flutter의 Widget, Web의 Jaspr Component 입니다.

속성타입기본값설명
triggerW필수호버를 감지할 트리거
contentW필수호버 시 표시할 카드 내용
placement CorePopoverPlacement bottom 카드가 표시될 방향
openDelay Duration? 500ms (CoreHoverCardStyle.defaultOpenDelay) 카드 표시까지의 지연 시간
closeDelay Duration? 500ms (CoreHoverCardStyle.defaultCloseDelay) 카드 닫힘까지의 지연 시간
onClose VoidCallback? null 카드가 닫힐 때 호출
hoverCardStyle CoreHoverCardStyle? null 패널 chrome / 치수 / 타이밍 오버라이드 (배경·보더·radius·padding·placementOffset·panelMaxWidth·openDelay·closeDelay·transitionDuration)

빠른 오버라이드 (Chain)#

이미 만든 HoverCard 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 == CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class HoverCardChainExample extends StatelessWidget {
  const HoverCardChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        HoverCard(
          trigger: const Text('@coui').bodyMedium.onPrimaryContainer.underline,
          content: const Text('크로스 플랫폼 UI 디자인 시스템.').bodySmall.onSurface,
          placement: CorePopoverPlacement.bottom,
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor·padding)까지 한 번에.
        HoverCard(
          trigger: const Text('Full control').bodyMedium.onPrimaryContainer.underline,
          content: const Text('크로스 플랫폼 UI 디자인 시스템.').bodySmall.onSurface,
          placement: CorePopoverPlacement.bottom,
        ).withStyle(
          const CoreHoverCardStyle(
            backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            padding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space24,
              vertical: CoreSpace.space8,
            ),
          ),
        ),
      ],
    );
  }
}
class HoverCardChainExample extends StatelessComponent {
  const HoverCardChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        HoverCard(
          trigger: const Text('@coui').bodyMedium.onPrimaryContainer.underline,
          content: const Text('크로스 플랫폼 UI 디자인 시스템.').bodySmall.onSurface,
          placement: CorePopoverPlacement.bottom,
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor·padding)까지 한 번에.
        HoverCard(
          trigger: const Text('Full control').bodyMedium.onPrimaryContainer.underline,
          content: const Text('크로스 플랫폼 UI 디자인 시스템.').bodySmall.onSurface,
          placement: CorePopoverPlacement.bottom,
        ).withStyle(
          const CoreHoverCardStyle(
            backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            padding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space24,
              vertical: CoreSpace.space8,
            ),
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

스타일 시스템 (Style System)#

HoverCard 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreHoverCardStyle 단일 슬롯으로 흐릅니다.

CoreHoverCardStyle 필드#

필드타입설명
backgroundColor CoreColor? Panel background colour override. null → [defaultBackgroundColor] ( surfaceBright ), forwarded into the composed Card 's style slot.
borderColor CoreColor? Panel border colour override. Deliberately without a default* : the panel border does not diverge from Card 's, so it is forwarded null into the composed Card 's style slot, whose resolver owns it. A default here would be a second copy of Card 's panel border that stops tracking it.
borderRadius CoreBorderRadius? Panel border-radius override. null → [defaultBorderRadius] (12).
padding CoreEdgeInsets? Panel content padding override.
placementOffset double? Placement-axis offset between the trigger edge and the floating panel override — projected onto the placement axis (top/bottom or left/right). The native overlay positioning value (not a child-gap slot).
panelMaxWidthdouble?Panel max-width override.
openDelay Duration? Delay before showing the hover card after pointer enter.
closeDelay Duration? Delay before hiding the hover card after pointer exit.
transitionDuration Duration? Panel enter/exit transition duration override.

변형 (Variants)#

방향 (Placement)#

카드가 트리거 기준으로 표시될 방향을 지정합니다.

// 상단 표시
HoverCard(
  trigger: const Text('상단').bodyMedium.onPrimaryContainer,
  content: const Text('상단에 표시').bodyMedium.onSurface,
  placement: CorePopoverPlacement.top,
)

// 우측 표시
HoverCard(
  trigger: const Text('우측').bodyMedium.onPrimaryContainer,
  content: const Text('우측에 표시').bodyMedium.onSurface,
  placement: CorePopoverPlacement.right,
)

// 좌측 표시
HoverCard(
  trigger: const Text('좌측').bodyMedium.onPrimaryContainer,
  content: const Text('좌측에 표시').bodyMedium.onSurface,
  placement: CorePopoverPlacement.left,
)

// 하단 표시 (기본)
HoverCard(
  trigger: const Text('하단').bodyMedium.onPrimaryContainer,
  content: const Text('하단에 표시').bodyMedium.onSurface,
  placement: CorePopoverPlacement.bottom,
)

동작 스펙 (Behavior)#

인터랙션#

  • 호버 진입: openDelay 후 카드 표시
  • 호버 이탈: closeDelay 후 카드 닫힘
  • 카드로 마우스 이동: 카드가 열린 상태 유지 (사용자가 카드와 인터랙션 가능)
  • 터치: Flutter는 플랫폼 long-press 인식, Web은 openDelay 타이머로 길게 누르기를 인식합니다
  • 자체 비활성 플래그·열림 제어 값·선택 콜백은 없습니다. hover 또는 long-press로 여는 보조 패널이며 중요한 액션의 유일한 진입점으로 사용하지 않습니다.

상태 전환#

  • hidden → visible: openDelay 이후 페이드 인
  • visible → hidden: closeDelay 이후 페이드 아웃

애니메이션#

양 플랫폼 모두 동일한 토큰을 사용하는 Fade + Scale 트랜지션입니다.

  • Open: 150 ms linear
  • Close: 67 ms 가시 모션 (100 ms wall-clock × Interval(0, 2/3))
  • Scale: 0.9 ↔ 1.0 (Flutter popover 의 collapsed / open scale 범위)
  • Open delay / Close delay: CoreHoverCardStyle.defaultOpenDelay / defaultCloseDelay (각 500 ms 기본). 트리거 hover-enter 후 openDelay 만큼 지연된 뒤 카드 표시 / hover-leave 후 closeDelay 만큼 유예 후 닫힘 (그 동안 패널 안으로 진입하면 닫힘 취소)

패널은 12 둥글기의 surfaceBright 바탕이고(떠 있는 판 공통), 트리거와는 기본 4 떨어집니다 — placementOffset · borderRadius · backgroundColor 로 인스턴스마다 바꿉니다. 테두리 색과 그림자는 합성한 Card 의 값입니다.

사용 가이드라인 (Usage Guidelines)#

✅ Do#

openDelay를 충분히 설정해 의도치 않은 표시 방지

HoverCard(
  trigger: const Text('@username').bodyMedium.onPrimaryContainer,
  content: UserProfileCard(username: 'username'),
  openDelay: const Duration(milliseconds: CoreDuration.slow),
)

너무 짧은 openDelay는 마우스가 지나갈 때마다 카드가 표시되어 방해가 된다.


❌ Don't#

터치 기기에서만 사용되는 UI에 HoverCard 적용

// ❌ 모바일 앱의 주요 정보를 HoverCard에만 표시
HoverCard(
  trigger: const Text('자세히').bodyMedium.onPrimaryContainer,
  content: ImportantInfo(), // 터치 사용자는 볼 수 없음
)

HoverCard는 마우스 호버가 가능한 환경에서만 동작하므로 중요 정보는 다른 방법으로도 접근 가능해야 한다.

✅ Do#

카드 내용에 사용자 프로필, 링크 미리보기 등 풍부한 정보 제공

HoverCard(
  trigger: const Text('@hong_gildong').bodyMedium.onPrimaryContainer,
  content: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Avatar(imageUrl: user.avatarUrl),
      Text(user.name).titleMedium.semiBold.onSurface,
      Text(user.bio).bodyMedium.onSurfaceVariant,
      Row(children: [
        Text('팔로워: ${user.followers}').labelSmall.onSurfaceVariant,
        const Gap.space16(),
        Text('팔로잉: ${user.following}').labelSmall.onSurfaceVariant,
      ]),
    ],
  ),
)

HoverCard는 Tooltip보다 많은 정보를 담을 수 있어 풍부한 미리보기에 적합하다.


❌ Don't#

필수 액션(버튼, 폼)을 HoverCard에만 배치하지 않기

// ❌ 호버해야만 볼 수 있는 중요한 액션
HoverCard(
  trigger: const Icon(LucideIcons.settings),
  content: Column(
    children: [
      Button(
        variant: CoreButtonVariant.destructive,
        onPressed: handleDeletePressed,
        child: const Text('계정 삭제'),
      ),
    ],
  ),
)

호버에서만 접근 가능한 액션은 키보드 사용자와 터치 사용자가 접근할 수 없다.

접근성 (Accessibility)#

역할#

트리거패널
Flutter Semantics(container: true, expanded: ...) — 카드가 열려 있는지만 노출하고 라벨은 붙이지 않습니다 (이름은 trigger 로 넘긴 위젯이 제공) Card 표면 그대로 — role 도 Semantics 도 없습니다
Web <div aria-haspopup="dialog" aria-expanded="true|false"> <div role="dialog">

Web 패널의 role="dialog" 에는 aria-label / aria-labelledby 가 붙지 않아 이름 없는 대화 상자로 노출됩니다. 트리거와 패널을 잇는 aria-controls / aria-describedby 도 없습니다 — 트리거는 카드가 열렸다는 사실만 알리고, 열린 것이 무엇인지는 가리키지 않습니다.

aria-haspopup / aria-expanded 는 컴포넌트 내부의 트리거 <div> 에 붙고 호출자의 attributes 는 그 바깥 루트 래퍼로 갑니다 — 이 두 값은 호출자가 덮을 수 없습니다.

키보드#

구현된 키가 없습니다. 카드는 포인터 입력(호버, 길게 누르기)으로만 열립니다.

키FlutterWeb
Tab 으로 트리거 도달 HoverCard 래퍼는 포커스 대상이 아님 (Focus / FocusNode 없음) 트리거 <div> 에 tabindex 없음
포커스로 열기 없음 — 포커스 리스너가 없어 trigger 로 넘긴 위젯 자체가 포커스 가능하더라도 카드는 열리지 않습니다 없음 — 같은 이유
Enter / Space 로 열기없음없음
Escape 로 닫기없음없음 (closeOnEsc: false)

길게 누르기는 양 플랫폼 모두 있습니다 — Flutter GestureDetector(onLongPress:), Web pointerdown → openDelay 타이머.

스크린 리더#

접근 가능한 이름은 trigger 로 넘긴 위젯이 그리는 내용에서만 옵니다 — 컴포넌트가 라벨을 붙이지 않습니다.

카드가 열리고 닫히는 것을 알리는 live region 은 없습니다. 상태 변화는 트리거의 expanded (Flutter) / aria-expanded (Web) 를 다시 읽어야만 드러납니다.

포커스 관리#

  • 진입 — 없음. Flutter 는 오버레이를 modal: false 로 띄워 모달 전용 requestFocus() 경로를 타지 않고, Web 은 패널로 포커스를 옮기는 코드가 없습니다.
  • 이탈 — 진입이 없으므로 복귀도 없습니다.
  • 트랩 — 없음. Web 패널은 #coui-overlay-popover 레이어로 포탈되어 문서 끝에 놓이고, 배경을 inert 로 만드는 것은 modal 로 마운트된 오버레이뿐인데 HoverCard 는 modal 로 마운트하지 않습니다. 카드 안에 포커스 가능한 요소를 넣으면 그것은 Tab 순서상 페이지 맨 뒤에 놓입니다.

알려진 제약#

  • 키보드만으로는 카드를 열 수 없습니다. 카드 안의 정보와 액션은 키보드 사용자가 도달할 수 없으므로, 카드 내용은 보조적인 미리 보기로만 쓰고 같은 정보·액션에 도달하는 다른 경로를 반드시 함께 두세요 (예: 트리거 자체를 Link / Button 으로 만들어 상세 화면으로 이동).
  • 트리거 크기에 하한이 없습니다. trigger 로 넘긴 위젯의 크기가 그대로 히트 영역이며 컴포넌트는 24×24 를 보장하지 않습니다. 길게 누르면 카드가 열리므로(양 플랫폼), 터치 환경을 지원한다면 호출자가 트리거에 충분한 히트 영역을 직접 확보해야 합니다.
  • 닫는 방법이 플랫폼마다 다릅니다. Web 은 바깥 클릭으로도 Escape 로도 닫히지 않고 포인터가 트리거·패널 양쪽을 벗어난 뒤 closeDelay 가 지나야만 닫힙니다. Flutter 는 비모달 오버레이의 바깥 탭 경로가 살아 있어 카드 밖을 탭하면 닫힙니다. 대안: onClose 와 호출부 상태로 닫힘을 관찰하되, Web 에서 포인터를 뗄 수 없는 입력(터치)만 쓰는 사용자에게는 확실한 닫기 수단이 없다는 점을 감안하세요.

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

항목FlutterWeb
클래스명HoverCardHoverCard
호버 감지 MouseRegion 위젯 JS mouseenter / mouseleave 이벤트
위치 계산 PopoverOverlayHandler 를 직접 호출해 root Overlay 마운트 OverlayHost.popover 레이어 portal + position: fixed + viewport 좌표
클리핑영향 없음 (root Overlay)영향 없음 (overlay host portal)
터치 지원플랫폼 GestureDetector.onLongPressopenDelay의 pointerdown 타이머
Open 애니메이션 150 ms linear, scale 0.9→1.0 150 ms linear, scale 0.9→1.0 — 동일
Close 애니메이션 67 ms 가시 (Interval(0, 2/3)) 67 ms 가시 (transition-duration 단축) — 동일
Open / Close delay CoreHoverCardStyle.defaultOpenDelay / defaultCloseDelay (각 500 ms) CoreHoverCardStyle.defaultOpenDelay / defaultCloseDelay (각 500 ms) — 동일
  • Popover: 클릭으로 열리는 더 복잡한 오버레이
  • Tooltip: 짧은 텍스트 힌트를 호버 시 표시

조합 예제#

// 소셜 피드에서 사용자 멘션 호버 카드
RichText(
  text: TextSpan(
    children: [
      const TextSpan(text: '안녕하세요, '),
      WidgetSpan(
        child: HoverCard(
          trigger: const Text('@홍길동').bodyMedium.onPrimaryContainer,
          content: UserHoverCard(username: '홍길동'),
          openDelay: const Duration(milliseconds: CoreDuration.slow),
        ),
      ),
      const TextSpan(text: ' 반가워요!'),
    ],
  ),
)