HoverCard#
트리거 요소 위에 마우스를 올리면 추가 정보를 담은 카드를 표시하는 컴포넌트입니다. 지연 시간을 조절하여 의도치 않은 표시를 방지할 수 있습니다.
Live Preview#
class HoverCardDefaultExample extends StatelessComponent {
const HoverCardDefaultExample({super.key});
@override
Component build(BuildContext context) {
return HoverCard(
trigger: const Text('@coui').bodyMedium.onPrimaryContainer.underline,
content: const Text('크로스 플랫폼 UI 디자인 시스템.').bodySmall.onSurface,
placement: CorePopoverPlacement.bottom,
);
}
}
class HoverCardDefaultExample extends StatefulWidget {
const HoverCardDefaultExample({super.key});
@override
State<HoverCardDefaultExample> createState() =>
_HoverCardDefaultExampleState();
}
class _HoverCardDefaultExampleState extends State<HoverCardDefaultExample> {
@override
Widget build(BuildContext context) {
return HoverCard(
trigger: const Text('@coui').bodyMedium.onPrimaryContainer.underline,
content: const Text('크로스 플랫폼 UI 디자인 시스템.').bodySmall.onSurface,
placement: CorePopoverPlacement.bottom,
);
}
}
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',
);
}
}
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,
),
),
),
],
);
}
}
사용 시기 (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). |
panelMaxWidth | double? | 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경과 시) 카드가 열림
상태 전환#
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만큼 유예 후 닫힘 (그 동안 패널 안으로 진입하면 닫힘 취소)
사용 가이드라인 (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)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | HoverCard | HoverCard |
| 호버 감지 | 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) — 동일 |
관련 컴포넌트 (Related Components)#
조합 예제#
// 소셜 피드에서 사용자 멘션 호버 카드
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: ' 반가워요!'),
],
),
)