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#
코드 보기
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,
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 사용자 멘션(@username), 링크 등에 호버 시 미리보기 카드를 표시할 때
- 아이콘이나 축약된 텍스트에 호버 시 상세 정보를 보여줄 때
- 클릭 없이 추가 맥락 정보를 제공하고 싶을 때
대신 다른 컴포넌트를 사용하세요:
- Tooltip: rich 콘텐츠·액션 없이 짧은 도움말 문자열만 표시할 때
- Popover: 호버 타이밍 대신 클릭·명령형 제어로 패널을 열어야 할 때
- 터치 기기에서는 트리거를 길게 눌러 카드를 열 수 있으나(long-press), 마우스 호버 환경이 주 사용처이므로 중요 정보는 다른 방법으로도 접근 가능하게 한다
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
trigger | W | 필수 | 호버를 감지할 트리거 |
content | W | 필수 | 호버 시 표시할 카드 내용 |
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). |
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는 플랫폼 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 는 그 바깥 루트 래퍼로 갑니다 — 이 두 값은 호출자가 덮을 수 없습니다.
키보드#
구현된 키가 없습니다. 카드는 포인터 입력(호버, 길게 누르기)으로만 열립니다.
| 키 | Flutter | Web |
|---|---|---|
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)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | HoverCard | HoverCard |
| 호버 감지 | MouseRegion 위젯 |
JS mouseenter / mouseleave 이벤트 |
| 위치 계산 | PopoverOverlayHandler 를 직접 호출해 root Overlay 마운트 |
OverlayHost.popover 레이어 portal + position: fixed + viewport 좌표 |
| 클리핑 | 영향 없음 (root Overlay) | 영향 없음 (overlay host portal) |
| 터치 지원 | 플랫폼 GestureDetector.onLongPress | openDelay의 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: ' 반가워요!'),
],
),
)