CSS 변수와 계산식
사용자 정의 속성의 선언·상속·범위를 익히고 calc로 단위가 다른 값을 결합해 재사용 가능한 스타일을 만듭니다.
앞 글에서는 트랜지션과 애니메이션으로 페이지에 움직임을 더했습니다. 이 글에서는 CSS를 더 고치기 쉽게 만드는 두 기능인 CSS 변수(custom property)와 계산식 calc()를 다룹니다. 예전의 CSS에는 변수가 없어서, 같은 색을 열 곳에 썼다면 색을 바꿀 때 열 곳을 모두 고쳐야 했습니다. 두 값을 조합해 새 값을 계산하기도 어려웠습니다.
CSS 변수는 값에 이름을 붙여 여러 곳에서 다시 쓰게 하고, calc()는 속성값 안에서 서로 다른 단위를 섞어 계산하게 합니다. 이 글에서는 변수를 선언하고 쓰는 방법, 변수가 적용되는 범위, calc()로 값을 계산하는 방법을 익힌 뒤, 두 기능으로 재사용할 수 있는 카드 컴포넌트를 만들어 봅니다.
CSS custom property 해석과 calc 계산 흐름 — 상태 추적
--gap 없음/20px/tomato를 치환한 뒤 width 타입 검사를 한다. tomato가 fallback 재시도 없이 auto가 되는 검사 시점과 후보값을 단계적으로 관찰할 가치가 있다.
CSS VALUES · FALLBACK BOUNDARY
var() fallback은 최종 속성 오류를 모두 복구하지 않는다
일반 custom property는 요소별 cascade·상속으로 값을 정하고 var()에서 치환된다. 치환할 값 선택과 최종 속성의 타입 검사는 서로 다른 단계다.
- Cascade·상속
같은 요소의 선언을 cascade로 결정하고, 일반 --변수는 부모에서 상속한다. 자식의 직접 선언과 등록된 @property 규칙은 별도로 확인한다.
- var() 치환
사용 가능한 값이 있으면 그 값을 넣는다. 미정의·순환 등으로 값이 무효면 두 번째 인수를 fallback으로 사용한다. fallback도 없거나 무효면 최종 속성이 무효가 된다.
- 최종 속성 검사
치환한 값과 calc()가 그 속성에서 허용되는 문법·타입인지 확인한다. 아래는 @property로 등록하지 않은 --gap을 width에 쓰는 비교다.
20px이 정의됨
--gap: 20px;
width: calc(100% -
var(--gap, 8px));20px을 치환한다. width에서 %와 px의 조합은 유효하다.
값이 미정의 또는 순환
width: calc(100% -
var(--gap, 8px));사용 가능한 --gap 값이 없으면 8px fallback을 치환하고 width를 계산한다.
값은 있지만 타입이 다름
--gap: tomato;
width: calc(100% -
var(--gap, 8px));tomato를 치환하므로 width가 무효다. 8px fallback으로 다시 시도하지 않는다.
계산 시 무효가 되어도 이전 cascade 후보로 돌아가지 않는다
위 width 오류는 계산 시점에 발견되므로 일반적인 비상속 속성 width의 initial 값인 auto로 처리된다. 상속 속성은 부모 값을 사용한다. +와 - 양옆 공백을 넣고, calc()는 사용하는 속성에서 허용하는 단위 조합으로 작성한다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 세 장면은 같은 선언 width: calc(100% - var(--gap, 8px))에 서로 독립적인 변수 조건 A·B·C를 적용한 비교입니다. containing block 크기가 정해지지 않아 최종 px 너비를 확정하지 않습니다. 변수 토큰과 width 계산식을 구분합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | Cascade·상속: 같은 요소의 선언을 cascade로 결정하고, 일반 --변수는 부모에서 상속한다. 자식의 직접 선언과 등록된 @property 규칙은 별도로 확인한다. |
| 원도표 조건·주석 | var() 치환: 사용 가능한 값이 있으면 그 값을 넣는다. 미정의·순환 등으로 값이 무효면 두 번째 인수를 fallback으로 사용한다. fallback도 없거나 무효면 최종 속성이 무효가 된다. |
| 원도표 조건·주석 | 최종 속성 검사: 치환한 값과 calc()가 그 속성에서 허용되는 문법·타입인지 확인한다. 아래는 @property로 등록하지 않은 --gap을 width에 쓰는 비교다. |
| 전체 관계·조건 설명 | 미등록 custom property인 --gap을 calc(100% - var(--gap, 8px))에서 읽는다. 사용할 값이 없으면 8px fallback을, 있으면 기존 값을 치환한다. 두 경로 모두 width 타입 검사를 거친다. 20px과 8px은 길이 계산에 맞지만 tomato는 계산 시 무효라 width의 초깃값 auto가 되며 fallback으로 되돌아가지 않는다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 조건 A · 변수 없음 | --gap: 없음 / width 식: calc(100% - 8px) | --gap이 없어 fallback 8px을 빼는 계산식으로 치환한다 | fallback은 변수 해석이 불가능할 때 선택한다 |
| 조건 B · 20px 값 | --gap: 20px / width 식: calc(100% - 20px) | 정의된 20px을 치환해 containing block 너비에서 빼는 식이 된다 | 정의된 유효 값이 fallback보다 먼저 사용된다 |
| 조건 C · tomato 값 | --gap: tomato / width: auto | calc(100% - tomato)는 width 문법에 맞지 않아 computed 단계에서 invalid가 된다 | 소비 속성이 거절하면 width는 이 사례에서 auto가 되며 8px fallback을 다시 고르지 않는다 |
CSS custom property 해석과 calc 계산 흐름 — 상태 추적
--gap 없음/20px/tomato를 치환한 뒤 width 타입 검사를 한다. tomato가 fallback 재시도 없이 auto가 되는 검사 시점과 후보값을 단계적으로 관찰할 가치가 있다.
CSS VALUES · FALLBACK BOUNDARY
var() fallback은 최종 속성 오류를 모두 복구하지 않는다
일반 custom property는 요소별 cascade·상속으로 값을 정하고 var()에서 치환된다. 치환할 값 선택과 최종 속성의 타입 검사는 서로 다른 단계다.
- Cascade·상속
같은 요소의 선언을 cascade로 결정하고, 일반 --변수는 부모에서 상속한다. 자식의 직접 선언과 등록된 @property 규칙은 별도로 확인한다.
- var() 치환
사용 가능한 값이 있으면 그 값을 넣는다. 미정의·순환 등으로 값이 무효면 두 번째 인수를 fallback으로 사용한다. fallback도 없거나 무효면 최종 속성이 무효가 된다.
- 최종 속성 검사
치환한 값과 calc()가 그 속성에서 허용되는 문법·타입인지 확인한다. 아래는 @property로 등록하지 않은 --gap을 width에 쓰는 비교다.
20px이 정의됨
--gap: 20px;
width: calc(100% -
var(--gap, 8px));20px을 치환한다. width에서 %와 px의 조합은 유효하다.
값이 미정의 또는 순환
width: calc(100% -
var(--gap, 8px));사용 가능한 --gap 값이 없으면 8px fallback을 치환하고 width를 계산한다.
값은 있지만 타입이 다름
--gap: tomato;
width: calc(100% -
var(--gap, 8px));tomato를 치환하므로 width가 무효다. 8px fallback으로 다시 시도하지 않는다.
계산 시 무효가 되어도 이전 cascade 후보로 돌아가지 않는다
위 width 오류는 계산 시점에 발견되므로 일반적인 비상속 속성 width의 initial 값인 auto로 처리된다. 상속 속성은 부모 값을 사용한다. +와 - 양옆 공백을 넣고, calc()는 사용하는 속성에서 허용하는 단위 조합으로 작성한다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 세 장면은 같은 선언 width: calc(100% - var(--gap, 8px))에 서로 독립적인 변수 조건 A·B·C를 적용한 비교입니다. containing block 크기가 정해지지 않아 최종 px 너비를 확정하지 않습니다. 변수 토큰과 width 계산식을 구분합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | Cascade·상속: 같은 요소의 선언을 cascade로 결정하고, 일반 --변수는 부모에서 상속한다. 자식의 직접 선언과 등록된 @property 규칙은 별도로 확인한다. |
| 원도표 조건·주석 | var() 치환: 사용 가능한 값이 있으면 그 값을 넣는다. 미정의·순환 등으로 값이 무효면 두 번째 인수를 fallback으로 사용한다. fallback도 없거나 무효면 최종 속성이 무효가 된다. |
| 원도표 조건·주석 | 최종 속성 검사: 치환한 값과 calc()가 그 속성에서 허용되는 문법·타입인지 확인한다. 아래는 @property로 등록하지 않은 --gap을 width에 쓰는 비교다. |
| 전체 관계·조건 설명 | 미등록 custom property인 --gap을 calc(100% - var(--gap, 8px))에서 읽는다. 사용할 값이 없으면 8px fallback을, 있으면 기존 값을 치환한다. 두 경로 모두 width 타입 검사를 거친다. 20px과 8px은 길이 계산에 맞지만 tomato는 계산 시 무효라 width의 초깃값 auto가 되며 fallback으로 되돌아가지 않는다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 조건 A · 변수 없음 | --gap: 없음 / width 식: calc(100% - 8px) | --gap이 없어 fallback 8px을 빼는 계산식으로 치환한다 | fallback은 변수 해석이 불가능할 때 선택한다 |
| 조건 B · 20px 값 | --gap: 20px / width 식: calc(100% - 20px) | 정의된 20px을 치환해 containing block 너비에서 빼는 식이 된다 | 정의된 유효 값이 fallback보다 먼저 사용된다 |
| 조건 C · tomato 값 | --gap: tomato / width: auto | calc(100% - tomato)는 width 문법에 맞지 않아 computed 단계에서 invalid가 된다 | 소비 속성이 거절하면 width는 이 사례에서 auto가 되며 8px fallback을 다시 고르지 않는다 |
위 그림은 width: calc(100% - var(--gap, 8px))에서 브라우저가 값을 정하는 과정을 보여 줍니다. 먼저 요소마다 cascade와 상속으로 --gap의 값을 정하고, var()에서 사용할 값이 있으면 그 값을, 정의되지 않았거나 순환 참조라 쓸 수 없으면 두 번째 인수인 8px을 대신 넣습니다. 그다음 넣은 값이 width에서 쓸 수 있는 타입인지 검사합니다. --gap: 20px이나 대신 넣은 8px은 길이라 계산이 되지만, --gap: tomato는 값이 있으므로 그대로 들어가고, 그 결과가 길이가 아니라 width 전체가 무효가 됩니다.
그림 아래의 설명처럼 이렇게 계산 시점에 무효가 된 width는 8px로 다시 시도하지 않고, cascade에서 밀려난 다른 width 선언으로 돌아가지도 않습니다. 상속되지 않는 속성인 width는 초기값인 auto가 됩니다. 그래서 대체값(fallback)은 변수에 값이 없을 때를 대비하는 장치일 뿐, 잘못된 타입의 값까지 바로잡아 주지는 않습니다.
CSS 변수
CSS 변수는 CSS 안에서 다시 쓸 수 있는 사용자 정의 속성입니다. 값에 이름을 붙여 선언하고, 여러 속성에서 var()로 그 값을 가져다 씁니다. 디자인 값을 한곳에서 관리하므로 일관성을 지키기 쉽고, 바꿀 때도 한 곳만 고치면 됩니다.
선언과 사용
변수는 이름 앞에 하이픈 두 개(--)를 붙인 속성으로 선언하고, var(--이름)으로 값을 가져옵니다. 다음 코드의 위쪽 :root 규칙이 변수 네 개를 선언하는 부분이고, 아래 세 규칙이 그 변수를 가져다 쓰는 부분입니다. .button의 padding처럼 var()는 calc() 안에도 넣을 수 있습니다.
/* 변수 선언 */
:root { /* 문서 전체에 적용되는 전역 변수 */
--primary-color: #3498db; /* 메인 색상 */
--secondary-color: #2ecc71; /* 보조 색상 */
--font-size-base: 16px; /* 기본 폰트 크기 */
--spacing-unit: 10px; /* 간격 단위 */
}
/* 변수 사용 */
body {
font-size: var(--font-size-base);
color: var(--primary-color);
}
.button {
background-color: var(--secondary-color);
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2); /* calc() 함수와 함께 사용 */
}
.header {
border-bottom: 1px solid var(--primary-color);
}:root는 문서의 루트 요소인 <html>을 가리키며, 여기에 선언한 변수는 문서 전체에서 쓸 수 있습니다. 변수 이름은 대소문자를 구분하므로 --primary-color와 --Primary-Color는 다른 변수입니다. var()는 속성값 안에서만 쓸 수 있으며, 선택자나 속성 이름, 미디어 쿼리의 조건을 변수로 바꿀 수는 없습니다.
변수의 범위
이 글에서 다루는 --이름 형태의 변수는 일반 속성처럼 cascade를 거치고, 기본으로 부모에서 자식으로 상속됩니다. @property 규칙으로 변수를 등록하면 값의 타입과 상속 여부를 따로 정할 수 있지만, 이 글의 예제는 등록하지 않습니다. 변수는 상속되므로, 어디에 선언했는지에 따라 쓸 수 있는 범위가 달라집니다.
:root에 선언한 변수는 모든 요소가 상속받으므로 문서 어디서든 쓸 수 있습니다.
:root {
--global-bg: #f0f0f0;
}
body {
background-color: var(--global-bg); /* 어디서든 사용 가능 */
}body에서 var(--global-bg)를 쓸 수 있는 것은 body가 <html>의 자손이어서 :root의 변수를 물려받기 때문입니다.
특정 선택자에 선언한 변수는 그 요소와 자손에서만 값을 가집니다. 다음 코드에서 .card-item은 .card-container 안에 있으므로 테두리 색을 물려받지만, 밖에 있는 .another-element에서는 변수에 값이 없어 var()의 두 번째 인수인 대체값이 쓰입니다.
.card-container {
--card-border-color: #ddd; /* .card-container와 그 자손에서만 값을 가짐 */
border: 1px solid var(--card-border-color);
}
.card-container .card-item {
border: 1px solid var(--card-border-color); /* 부모로부터 상속받아 사용 */
}
.another-element {
/* .card-container 밖이라 값이 없으므로 fallback인 transparent를 사용 */
background-color: var(--card-border-color, transparent);
}같은 요소에 여러 변수 선언이 맞으면 일반 속성과 같은 cascade 규칙으로 하나를 고르고, 그 요소에 선언이 없으면 부모의 값을 상속합니다. 그래서 자손에서 같은 변수를 다시 선언하면, 그 자손과 그 아래에서는 새 값이 쓰입니다. 다음 코드에서 .dark-theme 안의 문단은 --text-color를 .dark-theme에서 상속받아 밝은 색 글자가 되고, 나머지 문단은 :root의 어두운 색 글자가 됩니다.
:root {
--text-color: #333; /* 전역 텍스트 색상 */
}
.dark-theme {
--text-color: #eee; /* .dark-theme 내부에서는 텍스트 색상이 변경됨 */
background-color: #333;
}
p {
color: var(--text-color); /* 상황에 따라 다른 색상 적용 */
}이 때문에 부모의 변수 값을 바꿔도, 자손에서 그 변수를 다시 선언했다면 그 자손은 자기 값을 계속 씁니다.
변수를 쓰면 좋은 점
- 고치기 쉽습니다: 디자인을 바꿀 때 변수의 값 하나만 고치면 그 변수를 쓴 모든 곳이 바뀝니다.
- 일관성이 지켜집니다: 같은 값을 여러 번 손으로 적다가 생기는 실수가 줄어듭니다.
- 코드가 읽기 쉬워집니다:
#3498db대신--primary-color처럼 의미 있는 이름을 씁니다. - 테마를 바꾸기 쉽습니다: 어두운 테마의 변수 값을 클래스에 모아 두면, 요소에 그 클래스를 붙이고 떼는 것만으로 테마가 바뀝니다.
- 자바스크립트와 함께 쓸 수 있습니다: 자바스크립트로 변수의 값을 읽고 바꿔 스타일을 동적으로 바꿀 수 있습니다.
더 깊이: @property로 등록하면 무엇이 달라질까
등록하지 않은 변수에는 타입이 없습니다. 브라우저는 --angle: 45deg의 값을 각도가 아니라 글자의 나열로 보관했다가, var()로 쓰이는 자리에서 비로소 해석합니다. 그래서 --angle을 0deg에서 360deg로 트랜지션하거나 @keyframes로 움직이려 해도, 중간값을 계산할 방법이 없어 처음 값에서 마지막 값으로 한 번에 바뀝니다. 그라데이션의 각도처럼 변수로만 바꿀 수 있는 값을 부드럽게 움직이고 싶을 때 이 점이 걸립니다.
@property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; }처럼 등록하면 브라우저가 이 변수를 각도 타입으로 다루므로, 트랜지션과 애니메이션에서 중간값을 계산할 수 있습니다. syntax와 inherits는 반드시 적어야 하고, initial-value도 syntax가 아무 값이나 받는 "*"가 아니라면 필요합니다. 셋 가운데 필요한 것이 빠지면 규칙 전체가 무시됩니다. inherits: false로 등록하면 이 변수는 부모에서 자식으로 상속되지도 않습니다.
등록한 변수는 잘못된 값을 다루는 방식도 다릅니다. <color>로 등록한 변수에 23 같은 값을 넣으면, 그 선언을 버리는 대신 등록할 때 정한 initial-value가 쓰입니다. MDN의 @property 설명에 따르면 이 규칙은 2024년 7월부터 주요 브라우저의 최신 버전에서 함께 쓸 수 있습니다.
계산식: calc()
calc()는 속성값 안에서 덧셈(+), 뺄셈(-), 곱셈(*), 나눗셈(/)을 계산합니다. 특히 px, %, em, rem, vw, vh처럼 서로 다른 단위를 섞을 때 유용합니다. 계산하기 전에 먼저 각 단위의 기준을 확인합니다. width의 100%는 그 요소를 담는 부모 상자(containing block)의 너비이고, vh와 vw는 뷰포트의 크기입니다. 계산 결과도 그 속성에 맞는 타입이어야 합니다.
기본 문법
calc()의 괄호 안에 계산식을 적습니다. 계산은 브라우저가 화면을 그릴 때 하므로, 부모의 너비나 뷰포트 크기처럼 CSS를 쓰는 시점에는 알 수 없는 값도 식에 넣을 수 있습니다. 다음 네 줄은 차례로 백분율과 px, vh와 em, px와 vw, 변수와 숫자를 섞은 예입니다.
.element {
width: calc(100% - 40px); /* 부모 너비에서 40px을 뺀 너비 */
height: calc(50vh + 2em); /* 뷰포트 높이의 절반에 2em을 더한 높이 */
font-size: calc(16px + 0.5vw); /* 뷰포트 너비에 따라 폰트 크기 조절 */
margin-left: calc(var(--spacing-unit) / 2); /* 변수와 함께 사용 */
}계산식을 쓸 때는 몇 가지 규칙이 있습니다.
+와-의 양옆에는 반드시 공백을 둡니다.calc(100% - 20px)은 되지만calc(100%-20px)은-20px을 음수 하나로 읽어 문법 오류가 됩니다.*와/의 양옆 공백은 필수가 아니지만, 읽기 쉽게 띄웁니다.- 곱셈에서는 한쪽이, 나눗셈에서는 나누는 수인 오른쪽이 단위 없는 숫자여야 합니다.
100px * 2는 200px,100px / 2는 50px이지만2 / 100px은 무효입니다.100px * 2px처럼 길이끼리 곱하면 넓이가 되어width에 쓸 수 없습니다. 최근 일부 브라우저는100px / 2px처럼 같은 타입끼리 나눠 단위 없는 숫자를 얻는 계산도 지원합니다. - 0으로 나누지 않습니다. 나누는 수는 0이 아닌 양수로 정합니다.
calc() 활용 예
여백을 뺀 너비를 계산할 때 씁니다. 다음 코드는 부모 안에서 안쪽 여백만큼 너비를 줄인 예로, width의 계산식에서 빼는 40px이 padding의 좌우 값과 어떻게 맞물리는지 보면 됩니다.
.main-content {
width: calc(100% - 40px); /* 좌우 20px씩 총 40px의 패딩을 제외한 너비 */
margin: 20px auto;
padding: 20px;
}이 코드는 기본값인 content-box이고 테두리가 없다고 할 때, 부모의 너비가 500px이면 내용 폭이 460px, 좌우 안쪽 여백 40px을 더한 바깥 폭이 500px입니다. 같은 선언에 box-sizing: border-box를 주면 안쪽 여백이 460px 안에 들어가 바깥 폭도 460px이 됩니다. calc()가 box-sizing을 대신 바꿔 주지는 않으므로, 계산할 때 둘을 함께 확인합니다.
간격을 고려해 아이템 세 개의 너비를 나눌 때도 씁니다.
.grid-item {
/* 3개의 아이템을 나란히 배치하면서 20px의 간격을 고려한 너비 */
width: calc((100% - (20px * 2)) / 3); /* (전체 너비 - 총 간격) / 아이템 수 */
/* 또는 Grid의 gap 속성 사용 시: grid-template-columns: repeat(3, 1fr); gap: 20px; */
}이 식이 부모의 너비와 맞으려면 부모를 Flex 컨테이너로 만들고 실제 gap도 20px로 정해야, 아이템 너비 세 개와 간격 두 개를 더한 값이 부모의 너비가 됩니다. 주석처럼 Grid에서는 repeat(3, 1fr)과 gap: 20px만으로 같은 배치를 만들 수 있습니다.
최솟값과 최댓값을 함께 정하는 clamp()와도 씁니다. clamp(최소, 권장, 최대)는 권장값을 쓰되 최솟값보다 작아지거나 최댓값보다 커지지 않게 합니다. clamp() 안에서는 calc() 없이 2em + 2vw처럼 계산식을 바로 써도 됩니다.
h1 {
/* 최소 2em, 유동적인 권장값, 최대 4em을 함께 설정 */
font-size: clamp(2em, calc(2em + 2vw), 4em);
}이 예의 권장값 2em + 2vw는 언제나 2em보다 크므로, 실제로 작동하는 것은 최댓값 쪽입니다. font-size의 em은 부모의 글자 크기를 기준으로 하므로, 부모가 16px이라면 뷰포트 너비가 1600px 이상일 때 4em인 64px에서 더 커지지 않습니다.
변수와 함께 쓰면 간격의 기준값 하나로 여러 간격을 맞출 수 있습니다.
:root {
--base-padding: 15px;
}
.card {
padding: var(--base-padding);
margin-bottom: calc(var(--base-padding) * 2); /* 기본 패딩의 2배 마진 */
}--base-padding이 15px이면 .card의 안쪽 여백은 15px, 아래 바깥 여백은 30px입니다. 기준값을 20px로 바꾸면 안쪽 여백 20px, 바깥 여백 40px으로 함께 바뀝니다.
calc()를 쓰면 좋은 점
calc()를 쓰면 고정된 값과 화면에 따라 바뀌는 값을 섞어 유연한 레이아웃을 만들 수 있고, 서로 다른 단위를 함께 계산할 수 있으며, 미디어 쿼리 없이도 어느 정도 화면에 맞추는 배치를 만들 수 있습니다. 또 계산의 의도가 코드에 그대로 드러나, 기준값이 바뀌면 결과도 따라 바뀝니다.
실습: 변수로 만드는 카드 컴포넌트
CSS 변수와 calc()로 재사용할 수 있는 카드 컴포넌트와 어두운 테마 영역을 만들어 봅니다. 다음 구조로 파일을 준비합니다.
variables_calc.html에는 다음 코드를 씁니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS 변수와 calc() 실습</title>
<link rel="stylesheet" href="css/component.css">
</head>
<body>
<header>
<h1>CSS 변수 & calc()</h1>
<p>더 스마트한 CSS를 위한 도구</p>
</header>
<main>
<section class="section-container">
<h2>제품 카드 목록</h2>
<div class="product-grid">
<div class="product-card">
<h3>스마트폰</h3>
<p>최신 기술이 집약된 고성능 스마트폰입니다.</p>
<span class="price">990,000원</span>
<button class="buy-button">구매하기</button>
</div>
<div class="product-card">
<h3>무선 이어폰</h3>
<p>선명한 음질과 편안한 착용감을 제공합니다.</p>
<span class="price">159,000원</span>
<button class="buy-button">구매하기</button>
</div>
<div class="product-card">
<h3>스마트 워치</h3>
<p>건강 관리에 필수적인 스마트 워치.</p>
<span class="price">299,000원</span>
<button class="buy-button">구매하기</button>
</div>
<div class="product-card">
<h3>노트북</h3>
<p>뛰어난 성능과 휴대성을 겸비한 노트북.</p>
<span class="price">1,500,000원</span>
<button class="buy-button">구매하기</button>
</div>
</div>
</section>
<section class="section-container dark-theme">
<h2>다크 테마 예시</h2>
<div class="theme-box">
<p>이 박스는 다크 테마가 적용되어 있습니다. 변수의 스코프를 확인해보세요.</p>
<button class="theme-button">테마 전환 (JS로 구현 가능)</button>
</div>
</section>
</main>
<footer>
<p>© 2026 변수와 계산식 실습. 모든 권리 보유.</p>
</footer>
</body>
</html>css/component.css에는 다음 CSS를 씁니다. 색과 간격은 모두 :root의 변수로 정했고, .dark-theme은 색에 관한 변수만 다시 선언합니다.
/* 기본 설정 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f0f2f5;
color: var(--text-color, #333); /* 변수 사용, 폴백(fallback) 값 지정 */
line-height: 1.6;
padding-bottom: 50px;
}
/* CSS 변수 선언 (전역 스코프) */
:root {
--primary-color: #3498db; /* 파란색 */
--secondary-color: #2ecc71; /* 초록색 */
--accent-color: #e67e22; /* 주황색 */
--background-light: #f0f2f5; /* 밝은 배경색 */
--background-card: white; /* 카드 배경색 */
--text-color: #333; /* 기본 텍스트 색상 */
--border-color: #ddd; /* 테두리 색상 */
--box-shadow-light: 0 2px 10px rgba(0,0,0,0.1); /* 밝은 그림자 */
--base-padding: 15px; /* 기본 패딩 단위 */
--card-gap: 20px; /* 카드 간 간격 */
--button-padding-vertical: 10px; /* 버튼 세로 패딩 */
--button-padding-horizontal: 20px; /* 버튼 가로 패딩 */
}
/* 다크 테마 변수 오버라이드 (지역 스코프) */
.dark-theme {
--background-light: #2c3e50;
--background-card: #34495e;
--text-color: #ecf0f1;
--border-color: #4a6581;
--box-shadow-light: 0 4px 12px rgba(0,0,0,0.3);
}
/* 헤더 스타일 */
header {
background-color: var(--primary-color);
color: white;
text-align: center;
padding: calc(var(--base-padding) * 2) var(--base-padding);
box-shadow: var(--box-shadow-light);
margin-bottom: 30px;
}
header h1 {
font-size: 2.8em;
margin-bottom: calc(var(--base-padding) / 2);
}
header p {
font-size: 1.2em;
}
/* 섹션 공통 스타일 */
.section-container {
background-color: var(--background-light);
padding: calc(var(--base-padding) * 2);
border-radius: 8px;
box-shadow: var(--box-shadow-light);
max-width: 1200px;
margin: 0 auto 30px auto;
color: var(--text-color);
}
.section-container h2 {
color: var(--primary-color);
text-align: center;
margin-bottom: calc(var(--base-padding) * 2);
font-size: 2em;
}
/* 제품 카드 그리드 */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); /* 반응형 그리드 */
gap: var(--card-gap);
}
.product-card {
background-color: var(--background-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: var(--base-padding);
text-align: center;
box-shadow: var(--box-shadow-light);
display: flex; /* 내부 콘텐츠를 위한 Flexbox */
flex-direction: column;
justify-content: space-between;
align-items: center;
}
.product-card h3 {
font-size: 1.4em;
color: var(--primary-color);
margin-bottom: calc(var(--base-padding) / 2);
}
.product-card p {
font-size: 0.95em;
color: var(--text-color);
margin-bottom: var(--base-padding);
flex-grow: 1; /* 단락이 남은 공간 차지 */
}
.product-card .price {
font-size: 1.3em;
font-weight: bold;
color: var(--accent-color);
margin-bottom: var(--base-padding);
}
.buy-button {
background-color: var(--secondary-color);
color: white;
padding: var(--button-padding-vertical) var(--button-padding-horizontal);
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.3s ease;
}
.buy-button:hover {
background-color: #27ae60;
}
/* 다크 테마 박스 */
.theme-box {
background-color: var(--background-card);
border: 1px solid var(--border-color);
padding: calc(var(--base-padding) * 2);
border-radius: 8px;
text-align: center;
box-shadow: var(--box-shadow-light);
margin: 0 auto;
width: 80%;
max-width: 600px;
}
.theme-box p {
margin-bottom: var(--base-padding);
color: var(--text-color);
}
.theme-button {
background-color: var(--primary-color);
color: white;
padding: var(--button-padding-vertical) var(--button-padding-horizontal);
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.3s ease;
}
.theme-button:hover {
background-color: #2980b9;
}
/* 푸터 스타일 */
footer {
background-color: var(--primary-color);
color: white;
text-align: center;
padding: calc(var(--base-padding) + 5px) 0;
margin-top: 50px;
font-size: 0.9em;
}Live Server로 variables_calc.html을 엽니다. 헤더, 섹션 제목, 버튼의 파란색이 모두 --primary-color 하나에서 왔고, 간격은 --base-padding의 15px을 기준으로 계산됩니다. 헤더의 세로 안쪽 여백과 섹션의 안쪽 여백은 15 × 2 = 30px, 헤더 제목 아래의 간격은 15 / 2 = 7.5px, 푸터의 세로 안쪽 여백은 15 + 5 = 20px입니다. 개발자 도구의 Elements 패널에서 요소를 고른 뒤 Computed 영역을 보면 calc()가 계산한 최종 값을 확인할 수 있습니다.
아래쪽 섹션에는 .dark-theme 클래스가 붙어 있어, 그 안에서는 배경, 글자, 테두리, 그림자의 변수가 어두운 테마의 값으로 바뀝니다. --primary-color와 --base-padding은 다시 선언하지 않았으므로 제목의 파란색과 간격은 위 섹션과 같습니다. 또 섹션과 문단에는 color: var(--text-color)를 다시 적었으므로, 각 요소가 상속받은 --text-color로 자기 글자색을 다시 계산합니다. body에서 이미 계산된 글자색을 상속받는 것과는 다릅니다. :root의 --primary-color나 --base-padding을 바꿔 보면 페이지 전체가 함께 바뀌는 것을 볼 수 있습니다.
구매하기 버튼과 테마 전환 버튼에는 동작이 없습니다. 테마 전환은 자바스크립트로 섹션의 dark-theme 클래스를 붙이고 떼는 방식으로 만들 수 있으며, 뒤의 자바스크립트 장에서 다룹니다.
정리
- CSS 변수는
--이름: 값;으로 선언하고var(--이름)으로 가져오며, 속성값 안에서만 쓸 수 있습니다. - 변수는 cascade를 거치고 상속되므로,
:root에 선언하면 문서 전체에서, 특정 요소에 선언하면 그 요소와 자손에서 값을 가집니다. var(--이름, 대체값)의 대체값은 변수에 쓸 값이 없을 때만 쓰이며, 값이 있지만 타입이 맞지 않으면 속성 전체가 무효가 됩니다.calc()는 서로 다른 단위를 섞어 계산하며,+와-양옆에는 반드시 공백을 두고, 곱셈에서는 한쪽이, 나눗셈에서는 오른쪽이 단위 없는 숫자여야 합니다.calc()의%는 부모 상자를,vw와vh는 뷰포트를 기준으로 하며,box-sizing은 따로 확인합니다.clamp(최소, 권장, 최대)로 값의 범위를 정할 수 있고, 변수와calc()를 함께 쓰면 기준값 하나로 여러 값을 맞출 수 있습니다.
연습 문제
1. 기준 간격 바꾸기
:root의 --base-padding을 20px로 바꿨습니다. 헤더의 세로 안쪽 여백과 헤더 제목 아래의 간격은 얼마가 될까요? 이어서 .dark-theme에만 --base-padding: 12px를 더하면, 어두운 섹션의 안쪽 여백, 그 안의 .theme-box, 맨 위 헤더 가운데 무엇이 바뀔까요?
해설 보기
전역 값이 20px이면 헤더의 세로 안쪽 여백은 20 × 2 = 40px, 제목 아래 간격은 20 / 2 = 10px입니다.
.dark-theme에 12px을 선언하면 그 섹션과 자손이 12px을 쓰므로, 섹션의 안쪽 여백과 .theme-box의 안쪽 여백이 모두 12 × 2 = 24px이 됩니다. 어두운 섹션 바깥에 있는 헤더는 여전히 :root의 20px을 상속받으므로 세로 안쪽 여백이 40px 그대로입니다. 어두운 섹션에는 제품 카드가 없으므로 제품 카드도 바뀌지 않습니다.
2. 좁은 화면에서 넘치는 카드
너비 320px인 화면에서 제품 카드 목록이 가로로 넘칩니다. 그 이유를 계산으로 설명하고, 어떻게 고치면 좋을까요?
해설 보기
섹션의 좌우 안쪽 여백이 15 × 2 = 30px씩 모두 60px이므로, 카드 목록이 쓸 수 있는 폭은 320 − 60 = 260px입니다. 그런데 minmax(280px, 1fr)은 열이 280px보다 작아지지 않게 하므로, 한 열만 있어도 20px이 넘칩니다.
component.css 맨 뒤에 다음 규칙을 더하면, 공간이 넉넉할 때는 280px의 최솟값을 지키고 좁을 때는 부모 폭인 100%까지 줄어듭니다. min(280px, 100%)이 두 값 가운데 작은 값을 고르기 때문입니다.
.product-grid {
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
}3. 잘못된 타입의 변수
어떤 요소에 width: 300px;과, 그보다 명시도가 높은 width: calc(100% - var(--gap, 8px));가 함께 적용됩니다. 이 요소의 --gap 값이 tomato라면 너비는 어떻게 될까요?
해설 보기
auto가 됩니다. cascade에서 명시도가 높은 calc() 선언이 이미 골라졌고, 계산할 때 --gap에 값이 있으므로 대체값 8px이 아니라 tomato가 들어갑니다. 그 결과 100% - tomato가 길이가 아니라 width 전체가 무효가 됩니다.
이렇게 계산 시점에 무효가 되면 cascade에서 진 300px으로 돌아가지 않고, width의 초기값인 auto를 씁니다. 변수에는 그 변수를 쓸 속성에 맞는 타입의 값을 넣어야 합니다.
다음 글 CSS 아키텍처와 방법론에서는 BEM 같은 이름 규칙과 파일 구조로 큰 프로젝트의 CSS를 정리하는 방법을 다룹니다.