CSS — Box Model부터 반응형까지, 레이아웃 완전 정복
CSS에 대해 "왜?"라고 물으면 답할 수 있으신가요?
\n\n> CSS에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n# CSS — Box Model부터 반응형까지, 레이아웃 완전 정복
CSS 레이아웃은 범위도 넓고 깊이도 제각각이에요. "Flexbox와 Grid 차이가 뭔가요?"부터 "margin collapse는 어떤 상황에서 발생하나요?"까지, 자주 헷갈리는 부분이 많습니다. 이 글에서는 Box Model부터 반응형 디자인, CSS 방법론까지 CSS 레이아웃 관련 개념을 한 번에 정리해 보겠습니다.
Box Model
브라우저가 화면에 요소를 렌더링할 때, 모든 요소는 하나의 사각형 박스 로 취급됩니다. 이 박스의 구조를 Box Model이라고 부릅니다.
┌─────────────────────────────────┐
│ margin │
│ ┌───────────────────────────┐ │
│ │ border │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ padding │ │ │
│ │ │ ┌───────────────┐ │ │ │
│ │ │ │ content │ │ │ │
│ │ │ └───────────────┘ │ │ │
│ │ └─────────────────────┘ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
- content: 텍스트, 이미지 등 실제 콘텐츠가 들어가는 영역
- padding: content와 border 사이의 안쪽 여백
- border: padding 바깥의 테두리
- margin: border 바깥의 바깥 여백. 다른 요소와의 간격을 만듭니다
box-sizing: border-box
기본값인 content-box에서는 width가 content 영역의 너비만 의미해요. 그래서 padding이나 border를 주면 요소의 실제 크기가 width보다 커져버립니다. 레이아웃 잡을 때 이게 상당히 귀찮아요.
/* content-box (기본값) */
.box {
width: 200px;
padding: 20px;
border: 1px solid #000;
/* 실제 너비 = 200 + 20*2 + 1*2 = 242px */
}
/* border-box */
.box {
box-sizing: border-box;
width: 200px;
padding: 20px;
border: 1px solid #000;
/* 실제 너비 = 200px (padding, border 포함) */
}
거의 모든 프로젝트가 전역 리셋에 border-box를 넣습니다. width에 padding과 border가 포함되니까 레이아웃 계산이 직관적이에요.
*, *::before, *::after {
box-sizing: border-box;
}
display 속성
요소가 화면에서 어떻게 배치될지를 결정하는 속성입니다. 레이아웃의 근본이라고 봐도 돼요.
| 값 | 설명 |
|---|---|
block | 한 줄을 전부 차지한다. div, p, h1 등의 기본값. width/height 지정 가능 |
inline | 콘텐츠 크기만큼만 차지하고, 줄바꿈 없이 옆으로 나란히 놓인다. span, a, strong 등. width/height 지정 불가 |
inline-block | inline처럼 나란히 놓이면서도 width/height를 줄 수 있다. 두 가지 장점을 합친 것 |
none | 화면에서 완전히 사라진다. DOM에는 남아있지만 공간도 차지하지 않음. visibility: public과 다른 점은 공간 자체가 없어진다는 것 |
flex | 자식 요소를 1차원(가로 또는 세로)으로 배치하는 Flex 컨테이너가 된다 |
grid | 자식 요소를 2차원(행 + 열)으로 배치하는 Grid 컨테이너가 된다 |
inline과 inline-block은 뭐가 다를까요? 핵심은 width/height 지정 가능 여부 입니다. inline은 안 되고 inline-block은 됩니다. 그리고 inline 요소에 상하 margin은 적용되지 않는다는 것도 알아두면 좋아요.
position
요소의 위치를 잡는 방식을 지정하는 속성입니다. 각 값마다 기준점이 다르고, 사용하는 상황도 달라요.
static (기본값)
문서 흐름대로 배치됩니다. top, left 같은 오프셋 속성이 적용되지 않아요. 별도의 위치 지정이 필요 없을 때 쓰는데, 사실 기본값이니까 명시적으로 쓸 일은 거의 없습니다.
relative
원래 위치를 기준으로 오프셋만큼 이동합니다. 중요한 건 원래 자리의 공간은 그대로 유지 된다는 점이에요. 시각적으로만 이동하는 거지, 레이아웃에서의 위치는 변하지 않습니다. 자식 요소에 absolute를 적용할 때 기준점으로 삼기 위해 부모에 relative를 거는 경우가 가장 흔해요.
.parent {
position: relative;
}
absolute
가장 가까운 positioned 조상(position이 static이 아닌 조상)을 기준으로 배치됩니다. 만약 그런 조상이 없으면 <html>을 기준으로 삼아요. 문서 흐름에서 완전히 빠지기 때문에 다른 요소들이 이 요소가 없는 것처럼 배치됩니다.
.tooltip {
position: absolute;
top: 100%;
left: 0;
}
모달 내부의 닫기 버튼이나 드롭다운 메뉴처럼, 특정 부모 요소를 기준으로 정확한 위치에 놓아야 할 때 씁니다.
fixed
뷰포트(브라우저 창) 를 기준으로 배치됩니다. 스크롤해도 위치가 고정돼요. 네비게이션 바, "맨 위로" 버튼 같은 데 씁니다.
.navbar {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 100;
}
단, fixed의 기준이 항상 뷰포트인 건 아닙니다. 조상 요소에 transform, perspective, filter 속성이 있으면 그 요소가 기준이 되어버려요. 공부하다 보니 이 부분을 모르면 한참 삽질하게 되더라고요.
sticky
relative와 fixed를 합쳐놓은 느낌이에요. 평소에는 relative처럼 동작하다가, 스크롤해서 지정한 위치(예: top: 0)에 도달하면 fixed처럼 붙어서 따라옵니다. 테이블 헤더나 사이드바 같은 데 유용해요.
.table-header {
position: sticky;
top: 0;
background: white;
}
sticky가 안 먹히는 경우가 종종 있는데, 대부분 부모 요소에 overflow: hidden이나 overflow: auto가 걸려있어서 그렇습니다. sticky는 가장 가까운 스크롤 가능한 조상 안에서만 동작하기 때문이에요.
Flexbox
1차원 레이아웃 모델입니다. 한 방향(가로 또는 세로)으로 아이템을 배치하고, 정렬하고, 간격을 조절하는 데 씁니다.
주축과 교차축
Flexbox의 핵심 개념입니다. flex-direction에 따라 주축(main axis)이 결정되고, 그에 수직인 방향이 교차축(cross axis)이 됩니다.
flex-direction: row (기본값)
주축 → ───────────────────────────→
교차축 ↓ ┌─────┐ ┌─────┐ ┌─────┐
│ 1 │ │ 2 │ │ 3 │
└─────┘ └─────┘ └─────┘
flex-direction: column
주축 ↓ ┌─────┐
│ 1 │
├─────┤
│ 2 │
├─────┤
│ 3 │
교차축 → └─────┘
정렬 속성
| 속성 | 방향 | 설명 |
|---|---|---|
justify-content | 주축 | 아이템 간 간격과 정렬. flex-start, center, space-between, space-around, space-evenly |
align-items | 교차축 | 아이템의 교차축 정렬. stretch(기본), center, flex-start, flex-end, baseline |
align-self | 교차축 | 개별 아이템의 교차축 정렬을 오버라이드 |
align-content | 교차축 | flex-wrap: wrap일 때 여러 줄의 간격 |
gap | 양쪽 | 아이템 사이 간격. margin 안 써도 된다 |
가운데 정렬의 정석:
.container {
display: flex;
justify-content: center;
align-items: center;
}
예전에는 CSS로 수직 가운데 정렬하는 게 고역이었는데, Flexbox 나오고 나서 이 두 줄이면 끝납니다.
flex-grow, flex-shrink, flex-basis
아이템이 남은 공간을 어떻게 나눠 가질지(또는 공간이 부족할 때 어떻게 줄어들지)를 제어하는 속성입니다.
.item {
flex-grow: 1; /* 남은 공간을 1의 비율로 차지 */
flex-shrink: 1; /* 공간 부족 시 1의 비율로 줄어듦 */
flex-basis: 0; /* 초기 크기. auto면 콘텐츠 크기 기준, 0이면 비율만으로 결정 */
}
축약형 flex: 1은 flex: 1 1 0과 같습니다. 아이템을 균등하게 나누고 싶으면 모든 자식에 flex: 1을 주면 돼요.
flex-basis와 width는 어떻게 다를까요? flex-basis가 width보다 우선하고, flex-basis: auto일 때는 width 값을 사용합니다. 주축 방향의 초기 크기를 잡는 거라서, flex-direction: column이면 height 역할을 해요.
Grid
2차원 레이아웃 모델입니다. 행과 열을 동시에 제어할 수 있어서 전체 페이지 레이아웃이나 복잡한 그리드 구조를 잡을 때 Flexbox보다 적합해요.
기본 구조
.grid-container {
display: grid;
grid-template-columns: 200px 1fr 1fr;
grid-template-rows: auto 1fr auto;
gap: 16px;
}
fr 단위
fraction(비율) 의 약자입니다. 남은 공간을 비율로 나눠요. 1fr 2fr이면 1:2로 나눠 가집니다.
/* 3등분 */
grid-template-columns: 1fr 1fr 1fr;
/* 사이드바 200px 고정, 나머지를 메인이 차지 */
grid-template-columns: 200px 1fr;
repeat()과 minmax()
반복되는 패턴을 간결하게 쓸 수 있습니다.
/* 12칼럼 그리드 */
grid-template-columns: repeat(12, 1fr);
/* 각 칼럼이 최소 200px, 최대 1fr */
grid-template-columns: repeat(3, minmax(200px, 1fr));
auto-fill vs auto-fit
둘 다 repeat()과 함께 써서 칼럼 수를 자동으로 조절하는 건데, 빈 공간 처리 방식이 다릅니다.
/* auto-fill: 빈 칼럼도 공간을 유지 */
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
/* auto-fit: 빈 칼럼의 공간을 0으로 접어서 나머지 아이템이 늘어남 */
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
아이템 수가 적을 때 차이가 드러납니다. auto-fill은 빈자리가 남고, auto-fit은 있는 아이템이 공간을 채워요. 반응형 카드 레이아웃 같은 걸 만들 때 보통 auto-fit을 더 많이 씁니다.
Flexbox vs Grid, 언제 뭘 쓰나
| 상황 | 추천 |
|---|---|
| 한 줄 가로 정렬 (네비게이션, 버튼 그룹) | Flexbox |
| 수직 가운데 정렬 | Flexbox |
| 2차원 레이아웃 (페이지 전체 구조) | Grid |
| 카드 그리드 | Grid |
| 아이템 크기가 콘텐츠에 따라 유동적 | Flexbox |
| 정확한 행/열 크기 제어가 필요 | Grid |
실제로는 둘을 섞어 씁니다. 전체 레이아웃은 Grid로 잡고, 각 영역 내부는 Flexbox로 정렬하는 식이에요.
반응형 디자인
다양한 화면 크기에 맞게 레이아웃을 조절하는 접근 방식입니다.
Media Query
특정 조건(주로 화면 너비)에 따라 다른 스타일을 적용합니다.
/* 768px 이하에서 적용 */
@media (max-width: 768px) {
.sidebar {
display: none;
}
}
Mobile-First
작은 화면(모바일)의 스타일을 기본으로 작성하고, 큰 화면에 대해 min-width로 스타일을 추가하는 방식입니다. 요즘 대부분의 프로젝트가 mobile-first로 작성해요.
/* 기본: 모바일 */
.container {
flex-direction: column;
}
/* 태블릿 이상 */
@media (min-width: 768px) {
.container {
flex-direction: row;
}
}
/* 데스크톱 이상 */
@media (min-width: 1024px) {
.container {
max-width: 1200px;
margin: 0 auto;
}
}
desktop-first(max-width)보다 mobile-first(min-width)가 권장되는 이유는, 모바일 스타일이 보통 더 단순하기 때문입니다. 단순한 걸 기본으로 깔고 복잡한 걸 얹는 게 코드가 깔끔해요.
상대 단위
| 단위 | 기준 | 사용 예시 |
|---|---|---|
rem | html(루트) 요소의 font-size. 보통 16px | font-size, spacing에 주로 사용. 일관성 유지에 좋다 |
em | 부모 요소의 font-size | 컴포넌트 내부에서 비례 크기를 줄 때 |
vw | 뷰포트 너비의 1% | 전체 화면 너비에 비례하는 크기 |
vh | 뷰포트 높이의 1% | 히어로 섹션 높이 같은 데 |
rem이 실제로 가장 많이 쓰입니다. 루트 font-size를 기준으로 하니까 전체적으로 일관된 비율을 유지하기 쉽고, 사용자가 브라우저 글꼴 크기를 바꿨을 때도 자연스럽게 대응돼요. px은 고정값이라 접근성 측면에서 font-size에 쓰는 건 좋지 않습니다.
모바일에서 vh 쓸 때 주의할 점이 있습니다. 모바일 브라우저의 주소창 때문에 100vh가 실제 보이는 영역보다 클 수 있어요. 이걸 해결하려면 dvh(dynamic viewport height)를 쓰거나, CSS의 100svh/100lvh를 활용하면 됩니다.
CSS 선택자 우선순위 (Specificity)
같은 요소에 여러 스타일이 적용될 때, 어떤 스타일이 이기는지를 결정하는 규칙입니다.
Specificity 계산
우선순위는 (a, b, c) 형태의 가중치로 계산됩니다.
| 레벨 | 가중치 | 선택자 종류 |
|---|---|---|
| a | 가장 높음 | inline style (style="...") |
| b | 중간 | ID 선택자 (#id) |
| c | 낮음 | 클래스(.class), 속성([type]), 의사 클래스(:hover) |
| - | 가장 낮음 | 타입(div, p), 의사 요소(::before) |
/* (0, 0, 1) */
p { color: black; }
/* (0, 1, 0) */
.text { color: blue; }
/* (0, 1, 1) */
p.text { color: green; }
/* (1, 0, 0) */
#main { color: red; }
같은 specificity면 나중에 선언된 게 이깁니다. *, >, ~, + 같은 결합자는 specificity에 영향을 주지 않아요.
!important
specificity를 무시하고 최우선으로 적용됩니다. 하지만 !important끼리 충돌하면 다시 specificity로 비교하게 돼요.
.text {
color: blue !important; /* 이게 이김 */
}
#main {
color: red; /* specificity가 높아도 진다 */
}
!important를 남발하면 스타일 오버라이드가 점점 꼬이게 됩니다. 진짜 써야 하는 상황은 외부 라이브러리의 인라인 스타일을 덮어쓸 때 정도예요. 나머지 경우는 specificity를 올리거나 구조를 개선하는 게 맞습니다.
CSS 방법론
프로젝트 규모가 커지면 CSS 관리가 지옥이 됩니다. 이걸 해결하기 위한 여러 접근 방식이 있어요.
BEM (Block Element Modifier)
클래스 이름을 Block__Element--Modifier 형태로 짓는 네이밍 컨벤션입니다.
/* Block */
.card { }
/* Element */
.card__title { }
.card__body { }
/* Modifier */
.card--dark { }
.card__title--highlighted { }
장점은 클래스 이름만 보고도 구조를 파악할 수 있다는 거예요. 단점은 이름이 길어지고, 중첩이 깊어지면 block__element__sub-element 같은 괴물이 나올 수 있습니다.
CSS Modules
빌드 타임에 클래스 이름을 해시 값으로 변환해서 스코프를 격리합니다. React나 Vue 프로젝트에서 많이 써요.
import styles from './Card.module.css';
function Card() {
return <div className={styles.card}>...</div>;
}
컴파일 결과로 .card가 .Card_card_x7d2k 같은 고유 이름으로 바뀌니까 전역 충돌이 원천 차단됩니다. 다만 동적 스타일링이나 테마 적용은 불편할 수 있어요.
CSS-in-JS (styled-components, Emotion)
자바스크립트 안에서 CSS를 작성하는 방식입니다.
const Card = styled.div`
padding: 16px;
background: ${props => props.dark ? '#333' : '#fff'};
`;
props에 따라 스타일을 동적으로 바꿀 수 있고, 컴포넌트와 스타일이 한 파일에 있으니 관리가 편합니다. 하지만 런타임에 스타일을 생성하기 때문에 번들 크기가 커지고 성능 오버헤드가 있어요. 최근에는 이 문제 때문에 zero-runtime CSS-in-JS(vanilla-extract, Panda CSS 등)로 넘어가는 추세입니다.
Tailwind CSS
유틸리티 퍼스트 방식입니다. 미리 정의된 유틸리티 클래스를 조합해서 스타일을 입혀요.
<div class="p-4 bg-white rounded-lg shadow-md flex items-center gap-2">
<h2 class="text-lg font-bold text-gray-900">제목</h2>
</div>
CSS 파일을 따로 관리할 필요가 없고, 디자인 시스템의 토큰(spacing, color 등)을 강제해서 일관성을 유지하기 좋습니다. HTML이 지저분해 보인다는 비판이 있지만, 컴포넌트 기반 프레임워크와 함께 쓰면 큰 문제가 되지 않아요.
비교 정리
| 방법론 | 스코프 | 동적 스타일 | 런타임 비용 | 러닝 커브 |
|---|---|---|---|---|
| BEM | 컨벤션 의존 | 어렵다 | 없음 | 낮음 |
| CSS Modules | 자동 격리 | 제한적 | 없음 | 낮음 |
| CSS-in-JS | 자동 격리 | 자유롭다 | 있음 | 중간 |
| Tailwind | 유틸리티 | 조건부 클래스 | 없음 | 중간 |
z-index와 Stacking Context
z-index는 요소의 쌓임 순서를 제어하는 속성인데, 생각보다 직관적이지 않습니다. 왜냐하면 stacking context 개념이 있기 때문이에요.
Stacking Context란
z-index가 비교되는 범위를 만드는 일종의 울타리입니다. 서로 다른 stacking context에 속한 요소끼리는 z-index 값을 아무리 높여도 부모 context의 순서를 뛰어넘을 수 없어요.
.parent-a { position: relative; z-index: 1; }
.parent-b { position: relative; z-index: 2; }
/* parent-a 안의 자식 */
.child-a { position: absolute; z-index: 9999; }
/* parent-b 안의 자식 */
.child-b { position: absolute; z-index: 1; }
/* 결과: child-b가 child-a 위에 온다
parent-b(z-index:2) > parent-a(z-index:1)이니까 */
Stacking Context가 생기는 조건
position: relative/absolute/fixed/sticky+z-index값이auto가 아닌 경우opacity가 1보다 작은 경우transform,filter,perspective,clip-path등이 적용된 경우isolation: isolate가 적용된 경우
"분명 z-index: 9999를 줬는데 왜 안 올라오지?"라는 문제의 원인은 십중팔구 stacking context입니다.
심화 질문
Margin Collapse (마진 병합)
인접한 블록 요소의 상하 마진이 겹치면, 둘 중 큰 값 하나만 적용되는 현상입니다.
.box-a { margin-bottom: 20px; }
.box-b { margin-top: 30px; }
/* 실제 간격 = 30px (20 + 30 = 50이 아님) */
마진 병합이 발생하는 세 가지 경우가 있습니다:
- 인접 형제: 위 요소의 margin-bottom과 아래 요소의 margin-top
- 부모-자식: 부모에 padding/border가 없으면 자식의 margin-top이 부모 밖으로 빠져나갑니다
- 빈 블록: 콘텐츠, padding, border, height가 없는 요소의 상하 마진끼리
마진 병합을 막으려면 부모에 overflow: hidden을 주거나, padding/border를 넣거나, Flexbox/Grid 컨테이너를 쓰면 됩니다. Flexbox나 Grid 안에서는 마진 병합이 발생하지 않아요.
float와 clear
float는 원래 텍스트가 이미지를 감싸는 레이아웃(매거진 스타일)을 위해 만들어진 속성입니다. 한때 레이아웃 잡는 데 쓰였지만 지금은 Flexbox와 Grid가 있으니 레이아웃 용도로는 더 이상 쓸 이유가 없어요.
float된 요소는 일반 흐름에서 빠지기 때문에 부모가 높이를 잃는 문제가 생깁니다. 이걸 해결하는 게 clear인데, 전통적으로 clearfix 해킹을 많이 썼어요.
/* 고전적인 clearfix */
.clearfix::after {
content: "";
display: table;
clear: both;
}
핵심만 정리하면 "float는 원래 텍스트 감싸기 용도이고, 레이아웃 용도로는 Flexbox/Grid로 대체되었다"는 점입니다.
CSS 변수 (Custom Properties)
--로 시작하는 사용자 정의 속성입니다. Sass 변수와 달리 런타임에 동작하기 때문에 자바스크립트로 값을 바꿀 수도 있고, 미디어 쿼리 안에서 재정의도 가능해요.
:root {
--primary-color: #3b82f6;
--spacing-md: 16px;
}
.button {
background: var(--primary-color);
padding: var(--spacing-md);
}
/* 다크 테마 */
[data-theme="dark"] {
--primary-color: #60a5fa;
}
var()의 두 번째 인자는 폴백 값입니다. var(--color, #000)처럼 쓰면 변수가 정의되지 않았을 때 #000이 적용돼요.
CSS 변수는 cascading이 적용되므로, 하위 요소에서 재정의하면 해당 범위에서만 값이 바뀝니다. 이걸 이용하면 컴포넌트별 테마를 쉽게 구현할 수 있어요.
will-change
브라우저에게 "이 요소의 이 속성이 곧 변할 거다"라고 힌트를 주는 속성입니다. 브라우저가 미리 최적화를 준비할 수 있게 해줘요.
.animated-element {
will-change: transform, opacity;
}
하지만 남발하면 안 됩니다. will-change를 걸면 브라우저가 해당 요소를 별도의 레이어로 분리하는데, 레이어가 많아지면 오히려 메모리를 과도하게 사용하게 돼요. 실제로 애니메이션이 버벅거리는 요소에만 적용하고, 가능하면 애니메이션 시작 직전에 추가하고 끝나면 제거하는 게 이상적입니다.
파생 개념
이 글에서 다룬 CSS 레이아웃은 다른 프론트엔드 개념과 밀접하게 연결됩니다.
- HTML: CSS가 스타일링하는 대상입니다. 시맨틱 마크업에 따라 기본 display 속성이 달라지고, 접근성에도 영향을 줍니다.
- JavaScript: DOM 조작을 통해 스타일을 동적으로 변경하거나, CSS 변수 값을 런타임에 제어할 때 필요해요.
getComputedStyle()로 최종 적용된 스타일을 읽을 수도 있습니다. - 웹 성능 (Reflow / Repaint): CSS 속성 변경이 렌더링 파이프라인에 미치는 영향을 이해해야 합니다.
width,height,margin등을 바꾸면 reflow(레이아웃 재계산)가 발생하고,color,background만 바꾸면 repaint만 일어나요.transform과opacity는 컴포지터 레이어에서 처리되므로 reflow도 repaint도 발생하지 않아서 애니메이션 성능이 좋습니다.
주의할 점
1. box-sizing: border-box를 설정하지 않으면 레이아웃이 깨진다
기본값 content-box에서는 padding과 border가 width에 추가되어 예상보다 커집니다. *, *::before, *::after { box-sizing: border-box; }를 전역으로 설정하는 것이 표준이에요.
2. margin collapse를 모르면 세로 간격이 이상해진다
인접한 세로 margin은 합쳐지지 않고 큰 쪽만 적용됩니다. 부모-자식 사이에서도 발생하며, padding이나 border로 차단할 수 있어요.
정리
| 개념 | 핵심 |
|---|---|
| Box Model | content + padding + border + margin. border-box 쓰면 직관적 |
| display | block은 한 줄, inline은 흐름대로, flex/grid는 레이아웃 컨테이너 |
| position | static(기본), relative(원래 자리 기준), absolute(positioned 조상 기준), fixed(뷰포트), sticky(하이브리드) |
| Flexbox | 1차원 정렬. justify-content(주축), align-items(교차축) |
| Grid | 2차원 레이아웃. fr 단위, repeat, minmax로 유연하게 |
| 반응형 | mobile-first, min-width 미디어 쿼리, rem 단위 |
| Specificity | inline > ID > class > type. !important는 최후의 수단 |
| Stacking Context | z-index 비교의 울타리. transform, opacity 등으로 생성됨 |
| Margin Collapse | 상하 마진 병합. flex/grid 컨테이너 안에서는 안 일어남 |
| CSS 방법론 | BEM(컨벤션), Modules(빌드타임 격리), CSS-in-JS(런타임), Tailwind(유틸리티) |