HoverCard | CoUI
LogoCoUI

HoverCard

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

HoverCard#

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

Live Preview#

사용 시기 (When to Use)#

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

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

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

  • Tooltip: 짧은 텍스트 힌트만 표시할 때 (복잡한 카드 내용이 아닌 경우)
  • Popover: 클릭으로 표시되고 사용자 인터랙션이 필요한 오버레이에
  • 터치 기기에서는 트리거를 길게 눌러 카드를 열 수 있으나(long-press), 마우스 호버 환경이 주 사용처이므로 중요 정보는 다른 방법으로도 접근 가능하게 한다

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

Props / Parameters#

속성타입기본값설명
trigger Widget / Component 필수 호버를 감지할 트리거
content Widget / Component 필수 호버 시 표시할 카드 내용
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. Deliberately without a default* : the panel surface is a composed Card , and both resolvers forward this field into the nested CoreCardStyle slot still null so Card 's own resolver fills it. That is the partial-slot forward — the parent states only what diverges and never reaches into the child's defaults. A default here would be a second copy of Card 's panel colour that stops tracking it, and would repaint every hover card the day the two disagree.
borderColor CoreColor? Panel border colour override. Deliberately without a default* for the reason written on [backgroundColor] — it is forwarded null into the composed Card 's style slot, whose resolver owns the panel border.
borderRadius CoreBorderRadius? Panel border-radius override.
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 / Web 모두 트리거를 길게 누르면(long-press, openDelay 경과 시) 카드가 열림

상태 전환#

  • hiddenvisible: openDelay 이후 페이드 인
  • visiblehidden: 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 만큼 유예 후 닫힘 (그 동안 패널 안으로 진입하면 닫힘 취소)

사용 가이드라인 (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 쪽에 Focus / FocusNode 가 없고, Web 트리거 <div> 에는 tabindex 가 없어 Tab 순서에 들어가지 않습니다.
  • Escape 핸들러가 양 플랫폼 모두 없습니다. 닫힘은 closeDelay 경과(호버 이탈) 로만 일어납니다.

그래서 카드 안의 정보와 액션은 키보드 사용자가 도달할 수 없습니다. 카드 내용은 보조적인 미리 보기로만 쓰고, 같은 정보·액션에 도달하는 다른 경로를 반드시 함께 두세요 (예: 트리거 자체를 Link / Button 으로 만들어 상세 화면으로 이동).

스크린 리더#

플랫폼트리거패널
Flutter Semantics(container: true, expanded: ...) — 카드가 열려 있는지를 노출 Card 표면 (별도 role 없음)
Web aria-haspopup="dialog" + aria-expanded ( true / false ) role="dialog"

aria-describedby 배선은 없습니다 — 트리거는 카드가 열렸는지만 알리고, 패널 내용을 자기 설명으로 참조하지 않습니다.

터치 타겟#

컴포넌트는 트리거 크기에 하한을 두지 않습니다 — trigger 로 넘긴 위젯의 크기가 그대로 히트 영역입니다. 길게 누르면 카드가 열리므로(양 플랫폼), 터치 환경을 지원한다면 호출자가 트리거에 충분한 히트 영역을 직접 확보해야 합니다.

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

항목FlutterWeb
클래스명HoverCardHoverCard
호버 감지 MouseRegion 위젯 JS mouseenter / mouseleave 이벤트
위치 계산 PopoverOverlayHandler 를 직접 호출해 root Overlay 마운트 OverlayHost.popover 레이어 portal + position: fixed + viewport 좌표
클리핑영향 없음 (root Overlay)영향 없음 (overlay host portal)
터치 지원길게 누르기로 활성화길게 누르기로 활성화 (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: ' 반가워요!'),
    ],
  ),
)