개요
탭은 여러 콘텐츠 섹션을 겹쳐 배치하고 한 번에 하나의 패널만 표시합니다. 사용자는 탭 버튼을 클릭하거나 방향키로 탭 목록을 탐색해서 패널을 전환합니다.
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs>
<div ngTabList selectionMode="follow" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes">Reviews</div>
</div>
<div class="sliding-window">
<div ngTabPanel [preserveContent]="true" value="movie">
<ng-template ngTabContent>Panel 1</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="theatres">
<ng-template ngTabContent>Panel 2</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="showtimes">
<ng-template ngTabContent>Panel 3</ng-template>
</div>
</div>
</div>
app.css
:host {
font-family: var(--inter-font);
display: flex;
justify-content: center;
}
[ngTabs] {
overflow: hidden;
width: 600px;
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--primary-contrast) 10%, transparent);
}
[ngTabList] {
padding: 0;
display: flex;
list-style: none;
position: relative;
border-bottom: 1px solid color-mix(in srgb, var(--primary-contrast) 10%, transparent);
}
[ngTab] {
flex: 1;
outline: none;
padding: 0.75rem 0;
cursor: pointer;
text-align: center;
color: color-mix(in srgb, var(--primary-contrast) 60%, transparent);
}
[ngTab][aria-selected='false']:focus {
outline-offset: -8px;
border-radius: 0.7rem;
outline: 2px solid var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--bright-blue) 5%, transparent);
}
[ngTab][aria-selected='true'] {
color: var(--page-background);
background-color: var(--bright-blue);
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
.sliding-window {
width: 300%;
display: flex;
transition: all 0.2s ease-in-out;
}
[ngTabList]:has([ngTab]:nth-child(1)[aria-selected='true']) ~ .sliding-window {
transform: translateX(0%);
}
[ngTabList]:has([ngTab]:nth-child(2)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-33.333%);
}
[ngTabList]:has([ngTab]:nth-child(3)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-66.666%);
}
[ngTabPanel] {
display: grid;
place-items: center;
padding: 1rem;
min-height: 100px;
flex: 1;
border-bottom-right-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
[ngTabPanel]:focus {
outline-offset: -4px;
border-radius: 0.5rem;
outline: 2px solid var(--bright-blue);
}
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs class="material-tabs">
<div ngTabList selectionMode="follow" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes">Reviews</div>
<div class="bottom-border"></div>
</div>
<div class="sliding-window">
<div ngTabPanel [preserveContent]="true" value="movie">
<ng-template ngTabContent>Panel 1</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="theatres">
<ng-template ngTabContent>Panel 2</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="showtimes">
<ng-template ngTabContent>Panel 3</ng-template>
</div>
</div>
</div>
app.css
:host {
font-family: var(--inter-font);
display: flex;
justify-content: center;
}
[ngTabs] {
overflow: hidden;
width: 600px;
border-radius: 0.5rem;
background-color: color-mix(in srgb, var(--bright-blue) 5%, transparent);
}
[ngTabList] {
padding: 0;
display: flex;
list-style: none;
position: relative;
border-bottom: 1px solid color-mix(in srgb, var(--primary-contrast) 20%, transparent);
}
[ngTab] {
flex: 1;
outline: none;
padding: 1rem 0;
cursor: pointer;
text-align: center;
color: color-mix(in srgb, var(--primary-contrast) 60%, transparent);
}
[ngTab]:focus {
outline-offset: -8px;
border-radius: 0.7rem;
outline: 2px solid var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngTab][aria-selected='true'] {
color: var(--bright-blue);
}
.bottom-border {
position: absolute;
pointer-events: none;
left: 0;
bottom: 0;
height: 3px;
width: calc(100% / 3);
background-color: var(--bright-blue);
transition: all 0.2s ease-in-out;
transform: translateX(0%);
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
[ngTab]:nth-child(1)[aria-selected='true'] ~ .bottom-border {
transform: translateX(0%);
}
[ngTab]:nth-child(2)[aria-selected='true'] ~ .bottom-border {
transform: translateX(100%);
}
[ngTab]:nth-child(3)[aria-selected='true'] ~ .bottom-border {
transform: translateX(200%);
}
.sliding-window {
width: 300%;
display: flex;
transition: all 0.2s ease-in-out;
}
[ngTabList]:has([ngTab]:nth-child(1)[aria-selected='true']) ~ .sliding-window {
transform: translateX(0%);
}
[ngTabList]:has([ngTab]:nth-child(2)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-33.333%);
}
[ngTabList]:has([ngTab]:nth-child(3)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-66.666%);
}
[ngTabPanel] {
display: grid;
place-items: center;
padding: 1rem;
min-height: 100px;
flex: 1;
}
[ngTabPanel]:focus {
outline-offset: -4px;
border-radius: 0.5rem;
outline: 2px solid var(--bright-blue);
}
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs class="retro-tabs">
<div ngTabList selectionMode="follow" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes">Reviews</div>
</div>
<div ngTabPanel value="movie">
<ng-template ngTabContent>Line 1</ng-template>
</div>
<div ngTabPanel value="theatres">
<ng-template ngTabContent>Line 2</ng-template>
</div>
<div ngTabPanel value="showtimes">
<ng-template ngTabContent>Line 3</ng-template>
</div>
</div>
app.css
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: var(--bright-blue);
--retro-shadow-light: color-mix(in srgb, #fff 20%, transparent);
--retro-shadow-dark: color-mix(in srgb, #000 20%, transparent);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
[ngTabs] {
width: 600px;
}
[ngTabList] {
gap: 1rem;
display: flex;
}
[ngTab] {
flex: 1;
outline: none;
padding: 1rem;
cursor: pointer;
text-align: center;
color: #000;
background-color: #fff;
box-shadow: var(--retro-clickable-shadow);
transition:
transform 0.1s,
box-shadow 0.1s;
}
[ngTab]:focus,
[ngTab]:hover {
transform: translate(1px, 1px);
}
[ngTab]:focus {
outline-offset: 2px;
outline: 4px dashed var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--bright-blue) 50%, #fff);
}
[ngTab]:active,
[ngTab][aria-selected='true'] {
color: var(--page-background);
background-color: var(--bright-blue);
box-shadow: var(--retro-pressed-shadow);
transform: translate(4px, 4px);
}
[ngTabPanel] {
flex: 1;
font-size: 1.5rem;
color: #000;
background-color: #fff;
padding: 1rem;
margin-top: 1rem;
min-height: 100px;
display: grid;
place-items: center;
box-shadow: var(--retro-flat-shadow);
background: linear-gradient(rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 0.1) 50%);
background-color: color-mix(in srgb, var(--bright-blue) 10%, #fff);
background-size: 100% 4px;
}
[ngTabPanel]:focus {
outline-offset: 4px;
outline: 4px dashed var(--bright-blue);
}
[ngTabPanel][inert] {
display: none;
}
사용법
탭은 관련 콘텐츠를 서로 구분되는 섹션으로 구성하고, 사용자가 여러 뷰나 범주 사이를 전환하는 경우에 적합합니다.
탭을 사용하기 좋은 경우:
- 관련 콘텐츠를 구분된 섹션으로 구성할 때
- 여러 범주의 설정 패널을 만들 때
- 여러 주제로 구성된 문서를 만들 때
- 다양한 뷰를 갖춘 대시보드를 구현할 때
- 사용자가 맥락을 전환하며 확인해야 하는 콘텐츠를 표시할 때
탭을 피해야 하는 경우:
- 순서대로 작성하는 폼이나 마법사를 만들 때(단계 진행 패턴을 사용하세요)
- 페이지 사이를 이동할 때(라우터 탐색을 사용하세요)
- 콘텐츠 섹션이 하나뿐일 때(탭이 필요하지 않습니다)
- 탭이 7~8개보다 많을 때(다른 레이아웃을 고려하세요)
주요 기능
- 선택 모드: 포커스를 받으면 탭을 자동으로 활성화하거나 수동으로 활성화하도록 합니다.
- 키보드 탐색: 방향키, Home, End로 탭을 효율적으로 탐색합니다.
- 방향: 탭 목록을 수평이나 수직으로 배치합니다.
- 지연 콘텐츠: 탭 패널을 활성화할 때만 렌더링합니다.
- 비활성화된 탭: 개별 탭을 비활성화하고 포커스를 관리합니다.
- 포커스 모드: roving tabindex 또는 activedescendant 포커스 전략을 사용합니다.
- RTL 지원: 오른쪽에서 왼쪽으로 쓰는 언어의 탐색을 지원합니다.
예제
포커스에 따라 선택하기
포커스에 따라 선택하도록 설정하면 방향키로 탐색할 때 탭이 즉시 활성화됩니다. 사용자가 결과를 바로 확인할 수 있으며, 가벼운 콘텐츠에 적합합니다.
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs>
<div ngTabList selectionMode="follow" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes">Reviews</div>
</div>
<div class="sliding-window">
<div ngTabPanel [preserveContent]="true" value="movie">
<ng-template ngTabContent>Panel 1</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="theatres">
<ng-template ngTabContent>Panel 2</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="showtimes">
<ng-template ngTabContent>Panel 3</ng-template>
</div>
</div>
</div>
app.css
:host {
font-family: var(--inter-font);
display: flex;
justify-content: center;
}
[ngTabs] {
overflow: hidden;
width: 600px;
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--primary-contrast) 10%, transparent);
}
[ngTabList] {
padding: 0;
display: flex;
list-style: none;
position: relative;
border-bottom: 1px solid color-mix(in srgb, var(--primary-contrast) 10%, transparent);
}
[ngTab] {
flex: 1;
outline: none;
padding: 0.75rem 0;
cursor: pointer;
text-align: center;
color: color-mix(in srgb, var(--primary-contrast) 60%, transparent);
}
[ngTab][aria-selected='false']:focus {
outline-offset: -8px;
border-radius: 0.7rem;
outline: 2px solid var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--bright-blue) 5%, transparent);
}
[ngTab][aria-selected='true'] {
color: var(--page-background);
background-color: var(--bright-blue);
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
.sliding-window {
width: 300%;
display: flex;
transition: all 0.2s ease-in-out;
}
[ngTabList]:has([ngTab]:nth-child(1)[aria-selected='true']) ~ .sliding-window {
transform: translateX(0%);
}
[ngTabList]:has([ngTab]:nth-child(2)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-33.333%);
}
[ngTabList]:has([ngTab]:nth-child(3)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-66.666%);
}
[ngTabPanel] {
display: grid;
place-items: center;
padding: 1rem;
min-height: 100px;
flex: 1;
border-bottom-right-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
[ngTabPanel]:focus {
outline-offset: -4px;
border-radius: 0.5rem;
outline: 2px solid var(--bright-blue);
}
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs class="material-tabs">
<div ngTabList selectionMode="follow" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes">Reviews</div>
<div class="bottom-border"></div>
</div>
<div class="sliding-window">
<div ngTabPanel [preserveContent]="true" value="movie">
<ng-template ngTabContent>Panel 1</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="theatres">
<ng-template ngTabContent>Panel 2</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="showtimes">
<ng-template ngTabContent>Panel 3</ng-template>
</div>
</div>
</div>
app.css
:host {
font-family: var(--inter-font);
display: flex;
justify-content: center;
}
[ngTabs] {
overflow: hidden;
width: 600px;
border-radius: 0.5rem;
background-color: color-mix(in srgb, var(--bright-blue) 5%, transparent);
}
[ngTabList] {
padding: 0;
display: flex;
list-style: none;
position: relative;
border-bottom: 1px solid color-mix(in srgb, var(--primary-contrast) 20%, transparent);
}
[ngTab] {
flex: 1;
outline: none;
padding: 1rem 0;
cursor: pointer;
text-align: center;
color: color-mix(in srgb, var(--primary-contrast) 60%, transparent);
}
[ngTab]:focus {
outline-offset: -8px;
border-radius: 0.7rem;
outline: 2px solid var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngTab][aria-selected='true'] {
color: var(--bright-blue);
}
.bottom-border {
position: absolute;
pointer-events: none;
left: 0;
bottom: 0;
height: 3px;
width: calc(100% / 3);
background-color: var(--bright-blue);
transition: all 0.2s ease-in-out;
transform: translateX(0%);
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
[ngTab]:nth-child(1)[aria-selected='true'] ~ .bottom-border {
transform: translateX(0%);
}
[ngTab]:nth-child(2)[aria-selected='true'] ~ .bottom-border {
transform: translateX(100%);
}
[ngTab]:nth-child(3)[aria-selected='true'] ~ .bottom-border {
transform: translateX(200%);
}
.sliding-window {
width: 300%;
display: flex;
transition: all 0.2s ease-in-out;
}
[ngTabList]:has([ngTab]:nth-child(1)[aria-selected='true']) ~ .sliding-window {
transform: translateX(0%);
}
[ngTabList]:has([ngTab]:nth-child(2)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-33.333%);
}
[ngTabList]:has([ngTab]:nth-child(3)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-66.666%);
}
[ngTabPanel] {
display: grid;
place-items: center;
padding: 1rem;
min-height: 100px;
flex: 1;
}
[ngTabPanel]:focus {
outline-offset: -4px;
border-radius: 0.5rem;
outline: 2px solid var(--bright-blue);
}
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs class="retro-tabs">
<div ngTabList selectionMode="follow" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes">Reviews</div>
</div>
<div ngTabPanel value="movie">
<ng-template ngTabContent>Line 1</ng-template>
</div>
<div ngTabPanel value="theatres">
<ng-template ngTabContent>Line 2</ng-template>
</div>
<div ngTabPanel value="showtimes">
<ng-template ngTabContent>Line 3</ng-template>
</div>
</div>
app.css
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: var(--bright-blue);
--retro-shadow-light: color-mix(in srgb, #fff 20%, transparent);
--retro-shadow-dark: color-mix(in srgb, #000 20%, transparent);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
[ngTabs] {
width: 600px;
}
[ngTabList] {
gap: 1rem;
display: flex;
}
[ngTab] {
flex: 1;
outline: none;
padding: 1rem;
cursor: pointer;
text-align: center;
color: #000;
background-color: #fff;
box-shadow: var(--retro-clickable-shadow);
transition:
transform 0.1s,
box-shadow 0.1s;
}
[ngTab]:focus,
[ngTab]:hover {
transform: translate(1px, 1px);
}
[ngTab]:focus {
outline-offset: 2px;
outline: 4px dashed var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--bright-blue) 50%, #fff);
}
[ngTab]:active,
[ngTab][aria-selected='true'] {
color: var(--page-background);
background-color: var(--bright-blue);
box-shadow: var(--retro-pressed-shadow);
transform: translate(4px, 4px);
}
[ngTabPanel] {
flex: 1;
font-size: 1.5rem;
color: #000;
background-color: #fff;
padding: 1rem;
margin-top: 1rem;
min-height: 100px;
display: grid;
place-items: center;
box-shadow: var(--retro-flat-shadow);
background: linear-gradient(rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 0.1) 50%);
background-color: color-mix(in srgb, var(--bright-blue) 10%, #fff);
background-size: 100% 4px;
}
[ngTabPanel]:focus {
outline-offset: 4px;
outline: 4px dashed var(--bright-blue);
}
[ngTabPanel][inert] {
display: none;
}
이 동작을 활성화하려면 탭 목록에 [selectionMode]="'follow'"를 설정하세요.
수동 활성화
수동 활성화 모드에서는 방향키로 탭 사이의 포커스만 이동하고 선택된 탭은 바뀌지 않습니다. 사용자는 Space나 Enter를 눌러 포커스가 있는 탭을 활성화합니다.
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs>
<div ngTabList selectionMode="explicit" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes">Reviews</div>
</div>
<div class="sliding-window">
<div ngTabPanel [preserveContent]="true" value="movie">
<ng-template ngTabContent>Panel 1</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="theatres">
<ng-template ngTabContent>Panel 2</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="showtimes">
<ng-template ngTabContent>Panel 3</ng-template>
</div>
</div>
</div>
app.css
:host {
font-family: var(--inter-font);
display: flex;
justify-content: center;
}
[ngTabs] {
overflow: hidden;
width: 600px;
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--primary-contrast) 10%, transparent);
}
[ngTabList] {
padding: 0;
display: flex;
list-style: none;
position: relative;
border-bottom: 1px solid color-mix(in srgb, var(--primary-contrast) 10%, transparent);
}
[ngTab] {
flex: 1;
outline: none;
padding: 0.75rem 0;
cursor: pointer;
text-align: center;
color: color-mix(in srgb, var(--primary-contrast) 60%, transparent);
}
[ngTab][aria-selected='false']:focus {
outline-offset: -8px;
border-radius: 0.7rem;
outline: 2px solid var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--bright-blue) 5%, transparent);
}
[ngTab][aria-selected='true'] {
color: var(--page-background);
background-color: var(--bright-blue);
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
.sliding-window {
width: 300%;
display: flex;
transition: all 0.2s ease-in-out;
}
[ngTabList]:has([ngTab]:nth-child(1)[aria-selected='true']) ~ .sliding-window {
transform: translateX(0%);
}
[ngTabList]:has([ngTab]:nth-child(2)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-33.333%);
}
[ngTabList]:has([ngTab]:nth-child(3)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-66.666%);
}
[ngTabPanel] {
display: grid;
place-items: center;
padding: 1rem;
min-height: 100px;
flex: 1;
border-bottom-right-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
[ngTabPanel]:focus {
outline-offset: -4px;
border-radius: 0.5rem;
outline: 2px solid var(--bright-blue);
}
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs class="material-tabs">
<div ngTabList selectionMode="explicit" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes">Reviews</div>
<div class="bottom-border"></div>
</div>
<div class="sliding-window">
<div ngTabPanel [preserveContent]="true" value="movie">
<ng-template ngTabContent>Panel 1</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="theatres">
<ng-template ngTabContent>Panel 2</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="showtimes">
<ng-template ngTabContent>Panel 3</ng-template>
</div>
</div>
</div>
app.css
:host {
font-family: var(--inter-font);
display: flex;
justify-content: center;
}
[ngTabs] {
overflow: hidden;
width: 600px;
border-radius: 0.5rem;
background-color: color-mix(in srgb, var(--bright-blue) 5%, transparent);
}
[ngTabList] {
padding: 0;
display: flex;
list-style: none;
position: relative;
border-bottom: 1px solid color-mix(in srgb, var(--primary-contrast) 20%, transparent);
}
[ngTab] {
flex: 1;
outline: none;
padding: 1rem 0;
cursor: pointer;
text-align: center;
color: color-mix(in srgb, var(--primary-contrast) 60%, transparent);
}
[ngTab]:focus {
outline-offset: -8px;
border-radius: 0.7rem;
outline: 2px solid var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngTab][aria-selected='true'] {
color: var(--bright-blue);
}
.bottom-border {
position: absolute;
pointer-events: none;
left: 0;
bottom: 0;
height: 3px;
width: calc(100% / 3);
background-color: var(--bright-blue);
transition: all 0.2s ease-in-out;
transform: translateX(0%);
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
[ngTab]:nth-child(1)[aria-selected='true'] ~ .bottom-border {
transform: translateX(0%);
}
[ngTab]:nth-child(2)[aria-selected='true'] ~ .bottom-border {
transform: translateX(100%);
}
[ngTab]:nth-child(3)[aria-selected='true'] ~ .bottom-border {
transform: translateX(200%);
}
.sliding-window {
width: 300%;
display: flex;
transition: all 0.2s ease-in-out;
}
[ngTabList]:has([ngTab]:nth-child(1)[aria-selected='true']) ~ .sliding-window {
transform: translateX(0%);
}
[ngTabList]:has([ngTab]:nth-child(2)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-33.333%);
}
[ngTabList]:has([ngTab]:nth-child(3)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-66.666%);
}
[ngTabPanel] {
display: grid;
place-items: center;
padding: 1rem;
min-height: 100px;
flex: 1;
}
[ngTabPanel]:focus {
outline-offset: -4px;
border-radius: 0.5rem;
outline: 2px solid var(--bright-blue);
}
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs class="retro-tabs">
<div ngTabList selectionMode="explicit" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes">Reviews</div>
</div>
<div ngTabPanel value="movie">
<ng-template ngTabContent>Line 1</ng-template>
</div>
<div ngTabPanel value="theatres">
<ng-template ngTabContent>Line 2</ng-template>
</div>
<div ngTabPanel value="showtimes">
<ng-template ngTabContent>Line 3</ng-template>
</div>
</div>
app.css
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: var(--bright-blue);
--retro-shadow-light: color-mix(in srgb, #fff 20%, transparent);
--retro-shadow-dark: color-mix(in srgb, #000 20%, transparent);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--quaternary-contrast),
0px 4px 0px 0px var(--quaternary-contrast), -4px 0px 0px 0px var(--quaternary-contrast),
0px -4px 0px 0px var(--quaternary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--quaternary-contrast), 0px 4px 0px 0px var(--quaternary-contrast),
-4px 0px 0px 0px var(--quaternary-contrast), 0px -4px 0px 0px var(--quaternary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--quaternary-contrast),
0px 4px 0px 0px var(--quaternary-contrast), -4px 0px 0px 0px var(--quaternary-contrast),
0px -4px 0px 0px var(--quaternary-contrast), 8px 8px 0px 0px var(--quaternary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--quaternary-contrast),
0px 4px 0px 0px var(--quaternary-contrast), -4px 0px 0px 0px var(--quaternary-contrast),
0px -4px 0px 0px var(--quaternary-contrast), 0px 0px 0px 0px var(--quaternary-contrast);
}
[ngTabs] {
width: 600px;
}
[ngTabList] {
gap: 1rem;
display: flex;
}
[ngTab] {
flex: 1;
outline: none;
padding: 1rem;
cursor: pointer;
text-align: center;
color: #000;
background-color: #fff;
box-shadow: var(--retro-clickable-shadow);
transition:
transform 0.1s,
box-shadow 0.1s;
}
[ngTab]:focus,
[ngTab]:hover {
transform: translate(1px, 1px);
}
[ngTab]:focus {
outline-offset: 2px;
outline: 4px dashed var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--bright-blue) 50%, #fff);
}
[ngTab]:active,
[ngTab][aria-selected='true'] {
color: var(--page-background);
background-color: var(--bright-blue);
box-shadow: var(--retro-pressed-shadow);
transform: translate(4px, 4px);
}
[ngTabPanel] {
flex: 1;
font-size: 1.5rem;
color: #000;
background-color: #fff;
padding: 1rem;
margin-top: 1rem;
min-height: 100px;
display: grid;
place-items: center;
box-shadow: var(--retro-flat-shadow);
background: linear-gradient(rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 0.1) 50%);
background-color: color-mix(in srgb, var(--bright-blue) 10%, #fff);
background-size: 100% 4px;
}
[ngTabPanel]:focus {
outline-offset: 4px;
outline: 4px dashed var(--bright-blue);
}
[ngTabPanel][inert] {
display: none;
}
렌더링 비용이 큰 콘텐츠 패널에는 [selectionMode]="'explicit'"를 사용해서 불필요한 렌더링을 피하세요.
수직 탭
설정 패널이나 탐색 사이드바와 같은 인터페이스에서는 탭을 수직으로 배치할 수 있습니다.
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs>
<div ngTabList orientation="vertical" selectedTab="movie">
<div ngTab value="movie">
<span class="material-symbols-outlined" translate="no" aria-hidden="true">videocam</span>
</div>
<div ngTab value="theatres">
<span class="material-symbols-outlined" translate="no" aria-hidden="true"
>theater_comedy</span
>
</div>
<div ngTab value="showtimes">
<span class="material-symbols-outlined" translate="no" aria-hidden="true">reviews</span>
</div>
</div>
<div class="sliding-window">
<div ngTabPanel [preserveContent]="true" value="movie">
<ng-template ngTabContent>Panel 1</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="theatres">
<ng-template ngTabContent>Panel 2</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="showtimes">
<ng-template ngTabContent>Panel 3</ng-template>
</div>
</div>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
font-family: var(--inter-font);
display: flex;
justify-content: center;
}
[ngTabs] {
overflow: hidden;
width: 600px;
height: 200px;
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--primary-contrast) 10%, transparent);
display: flex;
}
[ngTabList] {
padding: 0;
display: flex;
flex-direction: column;
list-style: none;
position: relative;
border-right: 1px solid color-mix(in srgb, var(--primary-contrast) 10%, transparent);
}
[ngTab] {
flex: 1;
outline: none;
width: 80px;
cursor: pointer;
color: color-mix(in srgb, var(--primary-contrast) 60%, transparent);
display: grid;
place-items: center;
}
[ngTab][aria-selected='false']:focus {
outline-offset: -8px;
border-radius: 0.7rem;
outline: 2px solid var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--bright-blue) 5%, transparent);
}
[ngTab][aria-selected='true'] {
color: var(--page-background);
background-color: var(--bright-blue);
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
.sliding-window {
height: 300%;
display: flex;
flex-direction: column;
transition: all 0.2s ease-in-out;
flex: 1;
}
[ngTabList]:has([ngTab]:nth-child(1)[aria-selected='true']) ~ .sliding-window {
transform: translateY(0%);
}
[ngTabList]:has([ngTab]:nth-child(2)[aria-selected='true']) ~ .sliding-window {
transform: translateY(-33.333%);
}
[ngTabList]:has([ngTab]:nth-child(3)[aria-selected='true']) ~ .sliding-window {
transform: translateY(-66.666%);
}
[ngTabPanel] {
display: grid;
place-items: center;
padding: 1rem;
flex: 1;
}
[ngTabPanel]:focus {
outline-offset: -4px;
border-radius: 0.5rem;
outline: 2px solid var(--bright-blue);
}
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs>
<div ngTabList class="material-tabs" orientation="vertical" selectedTab="movie">
<div ngTab value="movie">
<span class="material-symbols-outlined" translate="no" aria-hidden="true">videocam</span>
</div>
<div ngTab value="theatres">
<span class="material-symbols-outlined" translate="no" aria-hidden="true"
>theater_comedy</span
>
</div>
<div ngTab value="showtimes">
<span class="material-symbols-outlined" translate="no" aria-hidden="true">reviews</span>
</div>
<div class="bottom-border"></div>
</div>
<div class="sliding-window">
<div ngTabPanel [preserveContent]="true" value="movie">
<ng-template ngTabContent>Panel 1</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="theatres">
<ng-template ngTabContent>Panel 2</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="showtimes">
<ng-template ngTabContent>Panel 3</ng-template>
</div>
</div>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
font-family: var(--inter-font);
display: flex;;
justify-content: center;
}
[ngTabs] {
overflow: hidden;
width: 600px;
height: 200px;
border-radius: 0.5rem;
background-color: color-mix(in srgb, var(--bright-blue) 5%, transparent);
display: flex;
}
[ngTabList] {
padding: 0;
display: flex;
flex-direction: column;
list-style: none;
position: relative;
border-right: 1px solid color-mix(in srgb, var(--primary-contrast) 20%, transparent);
}
[ngTab] {
flex: 1;
outline: none;
width: 80px;
cursor: pointer;
color: color-mix(in srgb, var(--primary-contrast) 60%, transparent);
display: grid;
place-items: center;
}
[ngTab]:focus {
outline-offset: -8px;
border-radius: 0.7rem;
outline: 2px solid var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngTab][aria-selected='true'] {
color: var(--bright-blue);
}
.bottom-border {
position: absolute;
pointer-events: none;
top: 0;
right: 0;
width: 3px;
height: calc(100% / 3);
background-color: var(--bright-blue);
transition: all 0.2s ease-in-out;
transform: translateY(0%);
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
}
[ngTab]:nth-child(1)[aria-selected='true'] ~ .bottom-border {
transform: translateY(0%);
}
[ngTab]:nth-child(2)[aria-selected='true'] ~ .bottom-border {
transform: translateY(100%);
}
[ngTab]:nth-child(3)[aria-selected='true'] ~ .bottom-border {
transform: translateY(200%);
}
.sliding-window {
height: 300%;
display: flex;
flex-direction: column;
transition: all 0.2s ease-in-out;
flex: 1;
}
[ngTabList]:has([ngTab]:nth-child(1)[aria-selected='true']) ~ .sliding-window {
transform: translateY(0%);
}
[ngTabList]:has([ngTab]:nth-child(2)[aria-selected='true']) ~ .sliding-window {
transform: translateY(-33.333%);
}
[ngTabList]:has([ngTab]:nth-child(3)[aria-selected='true']) ~ .sliding-window {
transform: translateY(-66.666%);
}
[ngTabPanel] {
display: grid;
place-items: center;
padding: 1rem;
flex: 1;
}
[ngTabPanel]:focus {
outline-offset: -4px;
border-radius: 0.5rem;
outline: 2px solid var(--bright-blue);
}
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs class="retro-tabs">
<div ngTabList orientation="vertical" selectedTab="movie">
<div ngTab value="movie">
<span class="material-symbols-outlined" translate="no" aria-hidden="true">videocam</span>
</div>
<div ngTab value="theatres">
<span class="material-symbols-outlined" translate="no" aria-hidden="true"
>theater_comedy</span
>
</div>
<div ngTab value="showtimes">
<span class="material-symbols-outlined" translate="no" aria-hidden="true">reviews</span>
</div>
</div>
<div ngTabPanel [preserveContent]="true" value="movie">
<ng-template ngTabContent>Line 1</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="theatres">
<ng-template ngTabContent>Line 2</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="showtimes">
<ng-template ngTabContent>Line 3</ng-template>
</div>
</div>
app.css
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: var(--bright-blue);
--retro-shadow-light: color-mix(in srgb, #fff 20%, transparent);
--retro-shadow-dark: color-mix(in srgb, #000 20%, transparent);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
[ngTabs] {
width: 500px;
display: flex;
}
[ngTabList] {
gap: 1rem;
display: flex;
flex-direction: column;
}
[ngTab] {
flex: 1;
outline: none;
padding: 1rem 1.5rem;
cursor: pointer;
text-align: center;
color: #000;
background-color: #fff;
box-shadow: var(--retro-clickable-shadow);
transition:
transform 0.1s,
box-shadow 0.1s;
}
[ngTab]:focus,
[ngTab]:hover {
transform: translate(1px, 1px);
}
[ngTab]:focus {
outline-offset: 2px;
outline: 4px dashed var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--bright-blue) 50%, #fff);
}
[ngTab]:active,
[ngTab][aria-selected='true'] {
color: var(--page-background);
background-color: var(--bright-blue);
box-shadow: var(--retro-pressed-shadow);
transform: translate(4px, 4px);
}
[ngTabPanel] {
flex: 1;
font-size: 1.5rem;
color: #000;
background-color: #fff;
padding: 1rem;
margin-left: 1rem;
min-width: 100px;
display: grid;
place-items: center;
box-shadow: var(--retro-flat-shadow);
background: linear-gradient(rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 0.1) 50%);
background-color: color-mix(in srgb, var(--bright-blue) 10%, #fff);
background-size: 100% 4px;
}
[ngTabPanel]:focus {
outline-offset: 4px;
outline: 4px dashed var(--bright-blue);
}
[ngTabPanel][inert] {
display: none;
}
탭 목록에 [orientation]="'vertical'"을 설정하세요.
탐색 키는 위/아래 방향키로 바뀝니다.
콘텐츠 지연 렌더링
ng-template에 ngTabContent 디렉티브를 적용하면 탭 패널을 표시할 때까지 렌더링을 미룰 수 있습니다.
<div ngTabs>
<ul ngTabList [(selectedTab)]="selectedTab">
<li ngTab value="tab1">Tab 1</li>
<li ngTab value="tab2">Tab 2</li>
</ul>
<div ngTabPanel value="tab1">
<ng-template ngTabContent>
<!-- 이 콘텐츠는 Tab 1을 표시할 때만 렌더링됩니다. -->
<app-heavy-component />
</ng-template>
</div>
<div ngTabPanel value="tab2">
<ng-template ngTabContent>
<!-- 이 콘텐츠는 Tab 2를 표시할 때만 렌더링됩니다. -->
<app-another-component />
</ng-template>
</div>
</div>
기본적으로 패널을 숨기면 콘텐츠가 DOM에서 제거됩니다.
예를 들어 탭을 전환해도 초기화되지 않아야 하는 동영상이 있다면, [preserveContent]="true"로 설정해서 콘텐츠를 유지하세요.
비활성화된 탭
특정 탭을 비활성화해서 사용자 조작을 막을 수 있습니다. 비활성화된 탭이 키보드 포커스를 받을 수 있는지도 제어할 수 있습니다.
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs>
<div ngTabList selectionMode="explicit" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes" disabled>Reviews</div>
</div>
<div class="sliding-window">
<div ngTabPanel [preserveContent]="true" value="movie">
<ng-template ngTabContent>Panel 1</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="theatres">
<ng-template ngTabContent>Panel 2</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="showtimes">
<ng-template ngTabContent>Panel 3</ng-template>
</div>
</div>
</div>
app.css
:host {
font-family: var(--inter-font);
display: flex;
justify-content: center;
}
[ngTabs] {
overflow: hidden;
width: 600px;
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--primary-contrast) 10%, transparent);
}
[ngTabList] {
padding: 0;
display: flex;
list-style: none;
position: relative;
border-bottom: 1px solid color-mix(in srgb, var(--primary-contrast) 10%, transparent);
}
[ngTab] {
flex: 1;
outline: none;
padding: 0.75rem 0;
cursor: pointer;
text-align: center;
color: color-mix(in srgb, var(--primary-contrast) 60%, transparent);
}
[ngTab][aria-disabled='true'] {
cursor: default;
color: color-mix(in srgb, var(--primary-contrast) 30%, transparent);
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngTab][aria-selected='false']:focus {
outline-offset: -8px;
border-radius: 0.7rem;
outline: 2px solid var(--bright-blue);
}
[ngTab][aria-disabled='false'][aria-selected='false']:hover {
background-color: color-mix(in srgb, var(--bright-blue) 5%, transparent);
}
[ngTab][aria-selected='true'] {
color: var(--page-background);
background-color: var(--bright-blue);
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
.sliding-window {
width: 300%;
display: flex;
transition: all 0.2s ease-in-out;
}
[ngTabList]:has([ngTab]:nth-child(1)[aria-selected='true']) ~ .sliding-window {
transform: translateX(0%);
}
[ngTabList]:has([ngTab]:nth-child(2)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-33.333%);
}
[ngTabList]:has([ngTab]:nth-child(3)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-66.666%);
}
[ngTabPanel] {
display: grid;
place-items: center;
padding: 1rem;
min-height: 100px;
flex: 1;
border-bottom-right-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
[ngTabPanel]:focus {
outline-offset: -4px;
border-radius: 0.5rem;
outline: 2px solid var(--bright-blue);
}
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs class="material-tabs">
<div ngTabList selectionMode="explicit" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes" disabled>Reviews</div>
<div class="bottom-border"></div>
</div>
<div class="sliding-window">
<div ngTabPanel [preserveContent]="true" value="movie">
<ng-template ngTabContent>Panel 1</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="theatres">
<ng-template ngTabContent>Panel 2</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="showtimes">
<ng-template ngTabContent>Panel 3</ng-template>
</div>
</div>
</div>
app.css
:host {
font-family: var(--inter-font);
display: flex;
justify-content: center;
}
[ngTabs] {
overflow: hidden;
width: 600px;
border-radius: 0.5rem;
background-color: color-mix(in srgb, var(--bright-blue) 5%, transparent);
}
[ngTabList] {
padding: 0;
display: flex;
list-style: none;
position: relative;
border-bottom: 1px solid color-mix(in srgb, var(--primary-contrast) 20%, transparent);
}
[ngTab] {
flex: 1;
outline: none;
padding: 1rem 0;
cursor: pointer;
text-align: center;
color: color-mix(in srgb, var(--primary-contrast) 60%, transparent);
}
[ngTab][aria-disabled='true'] {
cursor: default;
color: color-mix(in srgb, var(--primary-contrast) 30%, transparent);
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngTab]:focus {
outline-offset: -8px;
border-radius: 0.7rem;
outline: 2px solid var(--bright-blue);
}
[ngTab]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngTab][aria-selected='true'] {
color: var(--bright-blue);
}
.bottom-border {
position: absolute;
pointer-events: none;
left: 0;
bottom: 0;
height: 3px;
width: calc(100% / 3);
background-color: var(--bright-blue);
transition: all 0.2s ease-in-out;
transform: translateX(0%);
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
[ngTab]:nth-child(1)[aria-selected='true'] ~ .bottom-border {
transform: translateX(0%);
}
[ngTab]:nth-child(2)[aria-selected='true'] ~ .bottom-border {
transform: translateX(100%);
}
[ngTab]:nth-child(3)[aria-selected='true'] ~ .bottom-border {
transform: translateX(200%);
}
.sliding-window {
width: 300%;
display: flex;
transition: all 0.2s ease-in-out;
}
[ngTabList]:has([ngTab]:nth-child(1)[aria-selected='true']) ~ .sliding-window {
transform: translateX(0%);
}
[ngTabList]:has([ngTab]:nth-child(2)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-33.333%);
}
[ngTabList]:has([ngTab]:nth-child(3)[aria-selected='true']) ~ .sliding-window {
transform: translateX(-66.666%);
}
[ngTabPanel] {
display: grid;
place-items: center;
padding: 1rem;
min-height: 100px;
flex: 1;
}
[ngTabPanel]:focus {
outline-offset: -4px;
border-radius: 0.5rem;
outline: 2px solid var(--bright-blue);
}
app.ts
import {Component} from '@angular/core';
import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [TabList, Tab, Tabs, TabPanel, TabContent],
})
export class App {}
app.html
<div ngTabs class="retro-tabs">
<div ngTabList selectionMode="explicit" selectedTab="movie">
<div ngTab value="movie">Movie</div>
<div ngTab value="theatres">Cast</div>
<div ngTab value="showtimes" disabled>Reviews</div>
</div>
<div ngTabPanel value="movie">
<ng-template ngTabContent>Line 1</ng-template>
</div>
<div ngTabPanel value="theatres">
<ng-template ngTabContent>Line 2</ng-template>
</div>
<div ngTabPanel value="showtimes">
<ng-template ngTabContent>Line 3</ng-template>
</div>
</div>
app.css
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: var(--bright-blue);
--retro-shadow-light: color-mix(in srgb, #fff 20%, transparent);
--retro-shadow-dark: color-mix(in srgb, #000 20%, transparent);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
[ngTabs] {
width: 600px;
}
[ngTabList] {
gap: 1rem;
display: flex;
}
[ngTab] {
flex: 1;
outline: none;
padding: 1rem;
cursor: pointer;
text-align: center;
color: #000;
background-color: #fff;
box-shadow: var(--retro-clickable-shadow);
transition:
transform 0.1s,
box-shadow 0.1s;
}
[ngTab][aria-disabled='true'] {
cursor: default;
color: color-mix(in srgb, #000 30%, transparent);
}
[ngTab][aria-disabled='false'][aria-selected='false']:focus,
[ngTab][aria-disabled='false'][aria-selected='false']:hover {
transform: translate(1px, 1px);
}
[ngTab]:focus {
outline-offset: 2px;
outline: 4px dashed var(--bright-blue);
}
[ngTab][aria-disabled='false'][aria-selected='false']:hover {
background-color: color-mix(in srgb, var(--bright-blue) 50%, #fff);
}
[ngTab][aria-disabled='false']:active,
[ngTab][aria-selected='true'] {
color: var(--page-background);
background-color: var(--bright-blue);
box-shadow: var(--retro-pressed-shadow);
transform: translate(4px, 4px);
}
[ngTabPanel] {
flex: 1;
font-size: 1.5rem;
color: #000;
background-color: #fff;
padding: 1rem;
margin-top: 1rem;
min-height: 100px;
display: grid;
place-items: center;
box-shadow: var(--retro-flat-shadow);
background: linear-gradient(rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 0.1) 50%);
background-color: color-mix(in srgb, var(--bright-blue) 10%, #fff);
background-size: 100% 4px;
}
[ngTabPanel]:focus {
outline-offset: 4px;
outline: 4px dashed var(--bright-blue);
}
[ngTabPanel][inert] {
display: none;
}
탭 목록의 [softDisabled]="true"이면 비활성화된 탭도 포커스를 받을 수 있지만 활성화할 수는 없습니다.
[softDisabled]="false"이면 키보드 탐색에서 비활성화된 탭을 건너뜁니다.
테스트
Angular Aria는 탭 컴포넌트를 테스트할 수 있는 컴포넌트 하네스를 제공합니다. 컴포넌트 테스트에서 하네스를 사용하는 예제는 다음과 같습니다.
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {ComponentHarness, HarnessLoader} from '@angular/cdk/testing';
import {TabsHarness} from '@angular/aria/tabs/testing';
import {MyTabsComponent} from './my-tabs'; // 테스트할 컴포넌트
// 탭 패널 안의 콘텐츠를 조회하는 간단한 하네스입니다.
class TestContentHarness extends ComponentHarness {
static hostSelector = '.test-content';
async getText(): Promise<string> {
return (await this.host()).text();
}
}
describe('MyTabsComponent', () => {
let fixture: ComponentFixture<MyTabsComponent>;
let loader: HarnessLoader;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [MyTabsComponent],
});
fixture = TestBed.createComponent(MyTabsComponent);
await fixture.whenStable();
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('should switch tabs and scope panel queries', async () => {
const tabs = await loader.getHarness(TabsHarness);
// 모든 탭을 가져옵니다.
const tabItems = await tabs.getTabs();
expect(tabItems.length).toBe(3);
// 초기 선택 상태를 확인합니다.
expect(await tabItems[0].isSelected()).toBe(true);
expect(await tabItems[1].isSelected()).toBe(false);
// 활성화된 탭 패널 안의 콘텐츠를 조회합니다.
// TabHarness는 조회 범위를 연결된 패널로 자동 제한합니다.
const content = await tabItems[0].getHarness(TestContentHarness);
expect(await content.getText()).toBe('Content 1');
// 두 번째 탭으로 전환합니다.
await tabItems[1].select();
// 선택 상태가 변경되었는지 확인합니다.
expect(await tabItems[0].isSelected()).toBe(false);
expect(await tabItems[1].isSelected()).toBe(true);
});
});
API 레퍼런스
자세한 API 문서는 다음 API 레퍼런스를 참고하세요.