Link
내부 또는 외부 URL로 이동하는 링크 텍스트 컴포넌트. 문장 안에 놓이는 링크는 Underline=true를 쓴다(색만으로는 주변 글자와 구분되지 않는다). Underline=false는 단독으로 놓이는 링크(목록 · 푸터 · 카드 하단)에 쓰고, hover · pressed · focus 때 밑줄이 나타난다. muted는 보조 위치(푸터 등)의 링크다.
목차
내부 라우팅 또는 외부 URL로 이동하는 링크 텍스트 컴포넌트입니다. 링크 전용 색(color/link)으로 링크임을 알리고, 밑줄은 hover · pressed · focus 때 나타납니다. 문장 안에서는
Link.inlineSpan이 늘 밑줄로 그립니다.
기본 사용법 (Basic Usage)#
// 기본 링크 (링크 색, 밑줄은 hover · pressed · focus 때)
Link(
href: '#',
child: Text('Click here'),
)
// 외부 URL 링크 (새 탭)
Link(
href: 'https://example.com',
external: true,
child: Text('External Link'),
)
// 늘 밑줄인 링크
Link(
href: '#',
underline: true,
child: Text('Always underlined'),
)
// 보조 위치의 링크 (푸터, 캡션)
Link(
href: '#',
variant: CoreLinkVariant.muted,
child: Text('Terms of service'),
)
// 탭 콜백 사용
Link(
href: 'https://coui.cocode.im',
onPressed: handleOpenDocs,
child: Text('CoUI 문서'),
)
// 기본 링크 (링크 색, 밑줄은 hover · pressed · focus 때)
Link(
href: '#',
child: const Text('Click here'),
)
// 외부 URL 링크 (새 탭)
Link(
href: 'https://example.com',
external: true,
child: const Text('External Link'),
)
// 늘 밑줄인 링크
Link(
href: '#',
underline: true,
child: const Text('Always underlined'),
)
// 보조 위치의 링크 (푸터, 캡션)
Link(
href: '#',
variant: CoreLinkVariant.muted,
child: const Text('Terms of service'),
)
// 클릭 콜백 사용
Link(
href: 'https://coui.cocode.im',
onPressed: handleOpenDocs,
child: const Text('CoUI 문서'),
)
문장 안의 링크 (Inline Span)#
문단 · 표 셀 · 헤딩처럼 텍스트 흐름 안에 링크를 넣을 때는 위젯 Link 대신
Link.inlineSpan(...) 을 씁니다. 위젯 Link 는 Flutter 에서
WidgetSpan 으로만
문장에 들어가 조각째로 움직여 줄바꿈되지 않고, 라벨을 bodyMedium 역할로 그려
주변 글자와 크기가 달라집니다. Link.inlineSpan 은 주변 문장과 함께 줄바꿈되고
크기 · 굵기 · 행간을 주변에서 상속하며, 색만 CoreLinkStyle 에서 가져옵니다.
문장 안에서는 색만으로 링크를 알아보기 어려우므로 늘 밑줄로 그립니다 — 끄는 플래그가
없습니다. Web 에서는 같은 <a href> 이므로 단독 Link 와 같은 포커스 링을 그립니다.
// 탭 인식기는 이 State 가 소유하고 dispose 한다.
final _recognizers = <GestureRecognizer>[];
@override
void dispose() {
for (final r in _recognizers) {
r.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
for (final r in _recognizers) {
r.dispose();
}
_recognizers.clear();
return Text.rich(
TextSpan(
children: [
const TextSpan(text: '토큰을 덮어쓰기 전에 '),
Link.inlineSpan(
context,
href: '/docs/theming',
text: '테마 가이드',
onPressed: handleOpenTheming,
recognizers: _recognizers,
),
const TextSpan(text: '를 먼저 읽어 주세요.'),
],
),
).bodyMedium;
}
Component build(BuildContext context) {
return Component.element(
tag: 'p',
children: [
const Text('토큰을 덮어쓰기 전에 '),
Link.inlineSpan(context, href: '/docs/theming', text: '테마 가이드'),
const Text('를 먼저 읽어 주세요.'),
],
);
}
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
context |
BuildContext |
필수 (위치 인자) | 테마에서 CoreLinkStyle 을 해석할 컨텍스트 |
href |
String |
필수 | 이동할 URL. text · children 이 모두 없으면 이 문자열이 링크 텍스트가 됨 |
text | String? | null | 링크 텍스트 |
children |
List<InlineSpan>? (Flutter) / List<Component>? (Web) |
null |
링크 텍스트에 굵게 · 코드 같은 인라인 스타일이 있을 때. Flutter 는 안쪽 모든 텍스트 조각에 같은 탭 인식기를 붙임 |
onPressed |
VoidCallback? (Flutter) / CoreVoidCallback? (Web) |
null |
탭/클릭 시 호출. Web 은 href 를 유지한 채 이동 대신 콜백을 실행 |
external |
bool |
false |
외부 링크 여부. Web 에서 target="_blank" + rel="noopener noreferrer" |
variant |
CoreLinkVariant |
standard |
링크의 색 톤. muted 는 보조 텍스트 색 |
linkStyle |
CoreLinkStyle? |
null |
색 오버라이드. labelStyle 은 명시한 필드만 적용되고 타이포 역할은 주변을 따름 |
recognizers |
List<GestureRecognizer>? (Flutter 전용) |
null |
onPressed 가 있을 때 만든 TapGestureRecognizer 를 담을 목록. 호출자가 소유하고 dispose 함 |
Live Preview#
코드 보기
class LinkDefaultExample extends StatelessComponent {
const LinkDefaultExample({super.key});
@override
Component build(BuildContext context) {
return Link(
href: '#',
child: const Text('Click here'),
);
}
}
class LinkDefaultExample extends StatelessWidget {
const LinkDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return const Link(
href: '#',
child: Text('Click here'),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 본문 텍스트 중간에 인라인 링크가 필요할 때
- 외부 사이트로 이동하는 링크를 명시적으로 표시할 때
- 이용약관, 개인정보처리방침 같은 보조 링크가 필요할 때
대신 다른 컴포넌트를 사용하세요:
- Text: URL 이동·활성화 없이 정적 텍스트만 표시할 때
- Button: 인라인 링크 대신 버튼 chrome이 필요한 액션·이동 CTA일 때
- Breadcrumb: 목적지 하나 대신 부모-자식 탐색 경로를 표시할 때
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. W?
는 선택 슬롯입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
href |
String |
필수 | 이동할 URL. child가 없으면 이 문자열이 링크 텍스트로 렌더링됨 |
onPressed |
VoidCallback? (Flutter) / CoreVoidCallback? (Web) |
null |
활성화(탭/클릭/키보드 Enter) 시 호출되는 콜백 |
external |
bool |
false |
외부 링크 여부. Web에서 target="_blank" + rel="noopener noreferrer" 의도 적용 |
underline |
bool |
false |
밑줄을 늘 그릴지 여부.
false
면 hover · pressed · focus 때만
CoreLinkStyle.interactionTextDecoration
(기본 밑줄)이 나타남
|
variant |
CoreLinkVariant |
standard |
링크의 색 톤 — standard(링크 색) · muted(보조 텍스트 색). 밑줄 여부와 별개의 축 |
child |
W? |
null |
링크 내용. null이면 href가 일반 텍스트로 렌더링됨 |
linkStyle |
CoreLinkStyle? |
null |
색상 / 밑줄 색상 / 상호작용 밑줄 / 전환 duration / 라벨 텍스트 스타일 오버라이드 |
빠른 오버라이드 (Chain)#
이미 만든 Link 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class LinkChainExample extends StatelessWidget {
const LinkChainExample({super.key});
@override
Widget build(BuildContext context) {
return const Link(
href: '#',
child: Text('Click here'),
).withStyle(
const CoreLinkStyle(
color: CoreColor.token(CoreColors.secondary),
underlineColor: CoreColor.token(CoreColors.tertiary),
),
);
}
}
class LinkChainExample extends StatelessComponent {
const LinkChainExample({super.key});
@override
Component build(BuildContext context) {
return Link(
href: '#',
child: const Text('Click here'),
).withStyle(
const CoreLinkStyle(
color: CoreColor.token(CoreColors.secondary),
underlineColor: CoreColor.token(CoreColors.tertiary),
),
);
}
}
스타일 시스템 (Style System)#
모든 시각 chrome은 linkStyle 단일 슬롯(CoreLinkStyle)으로 전달합니다. 변형별 기본 색은
CoreLinkStyle.defaultsByVariant에, 프로젝트 단위 덮어쓰기는 CoreLinkTheme.style
·
CoreLinkTheme.variantStyles[variant]에 있습니다.
밑줄의 두께와 위치는 필드가 아닙니다 — 글꼴 자체의 값(post 표)을 쓰며, Web 은
text-underline-position: from-font + text-decoration-thickness: from-font, Flutter 는
TextDecoration 기본값입니다. 밑줄 색은 글자 색을 따라가고, underlineColor
를 지정했을 때만
따로 정해집니다.
Link(
href: '#',
linkStyle: const CoreLinkStyle(
color: CoreColor.token(CoreColors.secondary),
underlineColor: CoreColor.token(CoreColors.secondary),
),
child: Text('Secondary Link'),
)
CoreLinkStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
color | CoreColor? | Link text colour override. |
underlineColor |
CoreColor? |
Underline stroke colour override. Unset, the underline follows the text colour — including a [color] override. |
interactionTextDecoration |
CoreTextDecoration? |
Decoration shown while the link is hovered, pressed or focused — unlike the Button
link
variant's hover decoration, focus counts here, and it counts the same on both platforms. A link whose
underline
flag is set is always underlined and has nothing to reveal.
|
transitionDuration |
Duration? |
Colour-transition duration override for hover / focus feedback. |
labelStyle |
CoreTextStyle? |
Link label text style override (applied to the link's text child). |
clickableStyle |
CoreClickableStyle? |
Nested [CoreClickableStyle] slot for the composed
Clickable
(press scale / durations / focus ring / disabled opacity). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the remaining defaults.
|
CoreLinkStyle 변형별 기본값 (CoreLinkStyle)#
| 필드 | standard | muted |
|---|---|---|
color | link | onSurfaceVariant |
underlineColor | link | onSurfaceVariant |
변형 (Variants)#
Default#
링크 전용 색(color/link)을 씁니다. 밑줄은 hover · pressed · focus 때 나타납니다. 단독으로 놓이는 링크의 기본 모양입니다.
Link(
href: '#',
child: Text('Click here'),
)
Muted#
variant: CoreLinkVariant.muted 로 보조 텍스트 색을 씁니다. 푸터 · 캡션처럼 내용을 받치는 자리의 링크에 맞고, 링크 색이 내용과 다투지 않게 합니다.
Link(
href: '#',
variant: CoreLinkVariant.muted,
child: Text('Terms of service'),
)
Underline#
underline: true 로 밑줄을 늘 그립니다. 색만으로 링크를 알아보기 어려운 자리에 씁니다. 문장 안은 Link.inlineSpan
이 이 모양입니다.
Link(
href: '#',
underline: true,
child: Text('Always underlined'),
)
External#
external: true로 Web native anchor 경로에서 새 탭을 사용합니다. Flutter·콜백 경로는 호출부가 이동을 처리합니다.
Link(
href: 'https://example.com',
external: true,
child: Text('External Link'),
)
동작 스펙 (Behavior)#
인터랙션#
- 클릭/탭: onPressed가 있으면 콜백을 실행합니다. Flutter는 URL을 자체 이동하지 않으므로 호출부가 라우팅·외부 열기를 제공합니다
-
호버 · 눌림 · 포커스: 커서가 포인터로 바뀌고,
underline: false인 링크는 밑줄을 보입니다(CoreLinkStyle.interactionTextDecoration). 세 상태 모두 두 플랫폼에서 같습니다 -
Web 포커스 링:
<a href>경로도Clickable이 그리는 것과 같은 CoUI 포커스 링(2px · 간격 2)을 그립니다. 브라우저 기본 표시는 쓰지 않습니다 -
Web 네이티브 이동: onPressed가 없으면
<a href>를 렌더하고 external이면 새 탭을 사용합니다. 콜백 경로는 Clickable + data-href여서 href 자동 이동은 하지 않습니다 - 버튼 크롬을 가진 이동 CTA이면 Button 의 href 경로, 문장·보조 텍스트의 링크이면 Link입니다. Link에 콜백을 추가하면 Web의 native anchor 경로가 바뀌므로 분석용 콜백이 native 이동과 함께 필요할 때도 Button(href:, onPressed:) 계약을 확인하세요.
애니메이션#
-
색상 전환: 150ms ease-in-out (
CoreLinkStyle.transitionDuration). 밑줄은 나타나고 사라질 때 보간되지 않고 바뀝니다
사용 가이드라인 (Usage Guidelines)#
✅ Do#
외부 URL 에는 external: true 명시
Link(
href: 'https://example.com',
external: true,
child: Text('External Link'),
)
이유: external: true 는 Web 에서 target="_blank" + rel="noopener noreferrer" 를 실제로 적용하고, Flutter 에서도 onPressed 핸들러가 외부 이동 의도를 판단할 수 있는 신호가 됩니다. 생략하면 두 플랫폼 모두 새 탭/보안 처리를 임의로 판단해야 합니다.
❌ Don't#
탐색이 아닌 액션 트리거에 사용 금지
// ❌ 페이지 이동이 아니라 폼 제출 액션인데 Link 사용
Link(href: '#', onPressed: submitForm, child: Text('제출'))
이유: Link 는 Web role="link" / Flutter Semantics(link: true) 로 노출되어 스크린 리더가 "다른 곳으로 이동한다"고 안내합니다. 실제로는 폼을 제출하는 액션이라면 시맨틱과 실제 동작이 어긋나므로 Button 을 사용해야 합니다.
접근성 (Accessibility)#
역할#
-
Flutter: 링크 텍스트를
Semantics(button: true, link: true)로 감쌉니다 (link.dart:101-103). 그 바깥의Clickable이 다시Semantics(enabled: …, button: true)를 얹으므로 (clickable.dart:509-513), 이 컴포넌트는 링크이면서 동시에 버튼으로 표시됩니다. -
Web:
Clickable루트에role="link"를 실어Clickable의 기본role="button"을 덮습니다 (link.dart:127,clickable.dart:436·:441— 호출자·합성자 attributes 가 나중에 병합되어 이깁니다). 활성 상태이므로tabindex="0"이 함께 붙습니다 (clickable.dart:437).href는data-href,external은data-external="true"라는 data 속성으로만 나가며 (link.dart:128-129) 접근성 트리에는 노출되지 않습니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
Tab |
링크로 포커스 —
Clickable
의
FocusableActionDetector
(
clickable.dart:484-487
)
|
링크로 포커스 — tabindex="0" (clickable.dart:437) |
Enter |
onPressed
호출 —
SingleActivator(.enter) → ActivateIntent
(
clickable.dart:14-17
)
|
onPressed
호출 —
keydown
의
'Enter'
분기 (
clickable.dart:374-378
)
|
Space |
onPressed 호출 — SingleActivator(.space) (clickable.dart:14-17) |
onPressed
호출 —
keydown
의
' '
/
'Spacebar'
분기 (
clickable.dart:374-378
)
|
Space 로도 활성화되는 것은 Clickable 이 두 키를 함께 소유하기 때문이며, 네이티브 <a>(Enter 전용)와 다릅니다. 링크에 포커스가 있는 동안
Space 는 페이지 스크롤 대신 링크를 실행합니다.
Link.inlineSpan 은 Flutter 에서 Tab 으로 도달하지 않습니다 — TextSpan 은
포커스 노드가 아니기 때문입니다. 키보드로 반드시 닿아야 하는 링크는 위젯 Link 를
쓰세요.
스크린 리더#
접근 가능한 이름은 child 의 내용이고, child 를 생략하면 href 문자열이 그대로 이름이 됩니다 (Flutter
link.dart:104 child ?? Text(href), Web link.dart:111-113).
같은 링크가 플랫폼마다 다르게 안내됩니다 — Flutter 는 link 와 button 플래그를 함께 세워 두 역할이 겹쳐 읽히고, Web 은
role="link" 하나만 노출합니다.
live region 은 없습니다. 그리고 external: true 는 어느 플랫폼에서도 통보되지 않습니다 — Web 은 data-external
이라는 data 속성일 뿐이고 (link.dart:129), Flutter 의 build 는 external 을 읽지 않습니다 (link.dart:74-109).
포커스 관리#
오버레이가 아니므로 진입·이탈 시 포커스 이동이 없고 트랩도 없습니다. 포커스 표시는 Clickable.focusOutline 기본값이
true 라 양 플랫폼 모두 그려집니다 — Flutter 는 FocusOutline 위젯, Web 은 focus-visible:
의사 클래스 규칙입니다. Web 의 <a href> 경로(콜백 없음)와 Link.inlineSpan 은 Clickable
을 거치지 않으므로 같은 링을 앵커 자신이 그립니다 — 브라우저 기본 표시 대신 CoUI 링입니다. 포커스를 받으면 underline: false 링크도 밑줄을 보입니다.
알려진 제약#
-
external: true가 새 탭도, 보안 속성도, 통보도 만들지 않습니다 — Web 이 emit 하는 것은data-external="true"뿐이고target/rel은 어디에도 없습니다 (link.dart:123-142, 파일 전체target·noopener0건). "새 창에서 열림" 이 보조 기술에 전달되지 않고 실제 새 탭도 열리지 않습니다. 대안: 새 창 이동은onPressed에서 직접 구현하고,attributes진입점이 열려 있으므로 (link.dart:126— 호출자 attributes 가 먼저 펼쳐지고role/data-*만 덮어씁니다)attributes: {'aria-label': '문서 (새 창에서 열림)'}로 이름에 명시합니다. -
onPressed가 있으면 네이티브<a href>가 아닙니다 — Web 은 콜백이 있는 링크를<div role="link">로 렌더하고href는data-href로만 나가므로, 가운데 클릭 · 컨텍스트 메뉴의 "새 탭에서 열기" · 상태 표시줄 URL 미리보기 같은 브라우저 링크 어포던스가 없습니다. 이동만 하는 링크는 콜백을 주지 않으면 실제<a href>입니다. 대안: 이동 링크에는onPressed를 주지 않습니다. -
밑줄이 늘 있지는 않습니다 — 기본값(
underline: false)의 단독 링크는 hover · pressed · focus 때에만 밑줄을 보이므로 평소에는 색으로 구분됩니다. 그래서 문장 안의 링크에는 쓰지 마세요 — 주변 글자와 색으로만 갈리면 색각 이상 사용자와 OS 색 강제 모드에서 링크임을 알 수 없습니다(WCAG 1.4.1). 문장 안은Link.inlineSpan을 쓰면 늘 밑줄이고, 그 밖의 자리에서 늘 밑줄이 필요하면underline: true입니다. -
onPressed가null이어도 포커스를 받습니다 —Link는Clickable.enabled를 넘기지 않아 항상 기본값true로 합성됩니다 (Flutterclickable.dart:76, Webclickable.dart:91). 그래서 아무 일도 하지 않는 링크에도tabindex="0"이 붙고 Tab 이 멈춥니다. 대안: 핸들러가 없는Link를 렌더하지 않고Text를 씁니다.
이 컴포넌트가 직접 다루지 않는 대비·모션 등 전역 축은 전역 접근성 축 에서 한 곳으로 관리됩니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Link | Link |
| 렌더 요소 | Clickable + link 시맨틱·텍스트 | 콜백 없음: <a href>, 있음: Clickable의 <div role="link"> |
| 외부 링크 | 호스트가 onPressed에서 처리 | native anchor 경로에서만 target=_blank |
| 문장 안 링크 | Link.inlineSpan → TextSpan + TapGestureRecognizer |
Link.inlineSpan → 인라인 <a href> |
| 문장 안 링크의 포커스 | 포커스 링 · 키보드 활성화 없음 (TextSpan 은 포커스 노드가 아님) |
CoUI 포커스 링 · Enter (<a href> 가 포커스를 받음) |
| 상호작용 밑줄 상태 감지 | Clickable 의 hover · focus · pressed 콜백 → 다시 그림 |
CSS
hover:
·
focus-visible:
·
active:
(콜백 경로는
group-*
)
|
| 밑줄 두께 · 위치 | 글꼴 기본값 (TextDecoration) |
from-font (글꼴 기본값) |
관련 컴포넌트 (Related Components)#
- Button: 탐색이 아닌 액션 실행에 사용
- Text: 링크가 없는 일반 텍스트에 사용
- Breadcrumb: 계층적 페이지 탐색 경로 표시에 사용