리액트에서 CSS를 좀 더 편리하게 적용하기 위해서 사용하는 기술이 바로 Styled Components이다.
import styled from 'styled-components';
const Button = styled.button`
background-color: #ededed;
border: none;
border-radius: 8px;
`;
function App() {
return (
<div>
<h1>안녕 Styled Components!</h1>
<Button>확인</Button>
</div>
);
}
export default App;
위 코드는 Styled Components로 Button이라는 컴포넌트를 만든 예시이다. 일반적인 방법과 다르게 CSS 코드로 컴포넌트가 만들어진 부분이 Styled Components가 적용된 부분이다. 리액트는 JSX라는 문법으로 태그를 작성해 태그에 해당하는 컴포넌트를 만든다. 그렇게 만든 컴포넌트에 스타일을 적용하려면 해당 태그를 타겟한 CSS 코드를 따로 작성해야 했다.
하지만 Styled Components는 컴포넌트를 만들면서 바로 해당 컴포넌트의 스타일을 작성한다. 마치 JSX로 컴포넌트를 만드는 것처럼 리액트스럽게 CSS를 쓰는 방식이다. 이렇게 컴포넌트 중심으로 스타일을 지정하는 방식은 편리할 뿐만 아니라, 개발 속도도 빨라진다.
// App.js
import Dashboard from './Dashboard';
import App.css;
function App() {
return (
<div className="container">
<Dashboard> ... </Dashboard>
</div>
);
}
export default App
// Dashboard.js
import Dashboard.css;
function Dashboard({ children }) {
return (
<div className="container">
{children}
</div>
);
}
export default Dashboard;
/* App.css */
.container {
background-color: #000000;
}
/* Dashboard.css */
.container {
font-size: 16px;
}
App.js에 App이라는 컴포넌트를 만들고 클래스 이름을 .container로 지었다. Dashboard.js에는 Dashboard라는 컴포넌트를 만들고, 해당 컴포넌트에서의 클래스 이름도 .container로 지었다. 그렇게 만들어진 Dashboard 컴포넌트는 App 컴포넌트 안에 배치했다.
하지만 위 코드는 의도와는 다르게 동작한다. 두 컴포넌트 모두에서 App.css에 있는 스타일과 Dashboard.css에 있는 스타일이 모두 적용된다. 이는 사용된 클래스 이름이 전역적인 특성을 가지기 때문이다. 참고로, 클래스 이름이 전역적이라는 건 한 컴포넌트에서 사용한 클래스 이름을 다른 모든 컴포넌트에서도 사용할 수 있다는 뜻이다.
클래스 이름은 모든 곳에서 사용할 수 있기 때문에, import 해오지 않은 CSS 파일에서도 같은 클래스 이름이 사용된 부분이 있으면 그곳의 스타일이 함께 적용된다. 이렇게 의도하지 않은 방식으로 스타일이 적용되는 걸 막기 위해 클래스 이름은 겹치지 않도로고 조심해야 한다.
const StyledApp = styled.div`
background-color: #000000;
`;
const Dashboard = styled.div`
font-size: 16px;
`;
function App() {
return (
<StyledApp>
<Dashboard> ... </Dashboard>
</StyledApp>
);
}
Styled Components는 이 문제를 쉽게 해결한다. 클래스 이름을 아예 쓰지 않는 것이다. CSS 코드로 React 컴포넌트를 바로 만드니까 애초에 클래스 이름이 겹칠 일이 없다.
사이트를 만들다 보면 자주 쓰는 CSS 코드가 생기기 마련이다. 예를 들면 그림자 같은 게 있다. 그림자는 다양한 곳에서 자수 쓰지만 스타일의 종류는 몇 가지로 정해져 있다. 이렇게 자주 재사용되는 스타일은 각 종류 별로 클래스를 만들어 놓고 여러 컴포넌트에서 가져다 쓰면 편리하다. 하지만 CSS만으로는 재사용되는 코드를 잘 관리하는게 어렵다.
// App.js
import Dashboard from './Dashboard';
import Card from './Card';
import App.css;
function App() {
return (
<div className="container">
<Dashboard>
<Card> ... </Card>
</Dashboard>
</div>
);
}
export default App
// Card.js
import Card.css;
function Card({ children }) {
return (
<div className="container shadow20">
</div>
);
}
export default Card;
/* App.css */
.shadow20 {
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2);
}
.shadow40 {
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.4);
}
App 컴포넌트 안에 Dashboard를 배치하고, 그 안에 Card 컴포넌트를 배치하는 코드이다. App.css 파일에 .shadow20이라는 클래스를 만들어 두고 이걸 Card 컴포넌트에서 사용했다. (클래스 이름은 전역적이기 때문에 이렇게 사용 가능하다.)
여기서 문제는 App.css 코드에 정의된 shadow20만을 보고는 어디에 사용되는 스타일인지 알기 어렵다. JavaScript와 달리 CSS 코드는 VSCode 같은 코드 에디터에서 추적하기 어렵기 때문에 직접 테스트로 하나하나 검색을 해야 한다. 스타일이 재사용 되는 곳이 점점 더 많아질수록 코드를 유지 보수 할 때 관리가 더 힘들어진다.
// shadow.js
import { css } from 'styled-components';
const shadow20 = css`
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2);
`;
const shadow40 = css`
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.4);
`;
// Card.js
import { shadow20 } from '../shadows';
const Card = styled.div`
${shadow20}
...(다른 CSS 코드)
`;
export default Card;
Syled Components에서는 스타일 재사용이 필요한 상황에서 클래스가 아니라 JavaScript 변수를 만든다. 예시 코드에서 shadow20은 JavaScript 변수이다. JavaScript라서 언제 어디서 쓰고 있는지 에디터를 통해 확인하기 쉽고, 이름을 바꾸거나 삭제를 하는 것도 코드 에디터를 통해 쉽게 할 수 있다.
npm init react-app 프로젝트명
npm install styled-components
package.json 파일에 아래와 같이 추가되었을 것이다. 숫자 5는 버전을 의미한다.
{
...
"dependencies": {
...
"styled-components": "^5.3.5"
},
}
// Button.js
import styled from 'styled-components';
const Button = styled.button`
background-color: #6750a4;
border: none;
color: #ffffff;
padding: 16px;
`;
export default Button;
// App.js
import Button from './Button';
function App() {
return (
<div>
<Button>Hello Styled!</Button>
</div>
);
}
export default App;
styled 불러오기import styled from 'styled-components';
styled-components의 default import로 styled를 가져오면 된다. 대부분의 작업은 styled 함수를 사용한다.
Styled Components에서는 클래스 대신에 컴포넌트를 만든다.
const Button = styled.button`
background-color: #6750a4;
border: none;
color: #ffffff;
padding: 16px;
`;
styled.tagname의 tagname 부분에는 스타일을 적용할 HTML 태그 이름을 작성한다. 그리고 바로 뒤에 템플릿 리터럴 문법으로 CSS 코드를 작성한다. 태그 함수라는 걸 사용한 건데, 뒤에서 자세히 설명하도록 하겠다.
<Button>Hello Styled!</Button>
styled.tagname으로 만든 컴포넌트는 일반적인 리액트 컴포넌트처럼 JSX로 사용하면 된다.
& 선택자& 선택자를 사용해서 앞에서 만든 버튼 컴포넌트를 호버하거나 클릭했을 때 배경색이 바뀌도록 만들어 보겠다.
// Button.js
import styled from 'styled-components';
const Button = styled.button`
background-color: #6750a4;
border: none;
color: #ffffff;
padding: 16px;
&:hover,
&:active {
background-color: #463770;
}
`;
export default Button;
Nesting에서 &는 부모 선택자를 의미한다. 위 코드에서는 버튼 컴포넌트의 클래스를 뜻한다. 기존 CSS 코드로 표현해 본다면, 버튼 컴포넌트가 .Button이라는 클래스 일므을 쓸 때 &:hover는 .Button:hover와 같은 의미이다.
.Button {
background-color: #6750a4;
border: none;
color: #ffffff;
padding: 16px;
}
.Button:hover,
.Button:active {
background-color: #463770;
}
Styled Components에선 클래스 이름을 사용하지 않는데, 컴포넌트 안에 있는 또 다른 컴포넌트를 선택하고 싶을 때는 어떻게 해야 하는가?
버튼 안에 아이콘을 배치하는 상황을 가정하여, 버튼 텍스트 왼쪽에 아이콘을 배치하고 그 사이에 마진을 4px만큼 주려고 한다.
Styled Components로 Icon과 StyledButton 컴포넌트를 각각 만들고, StyledButton 안에 Icon을 배치한다. 이 때 StyledButton 컴포넌트 안에서 Icon 컴포넌트를 선택해 별도로 margin-right: 4px라는 속성을 지정한다. 이럴 경우, 컴포넌트를 선택자로 쓰고 싶을 때는 ${Icon} 같이 컴포넌트 자체를 템플릿 리터럴 안에 넣어주면 된다.
import styled from 'styled-components';
import nailImg from './nail.png';
const Icon = styled.img`
width: 16px;
height: 16px;
`;
const StyledButton = styled.button`
background-color: #6750a4;
border: none;
color: #ffffff;
padding: 16px;
& ${Icon} {
margin-right: 4px;
}
&:hover,
&:active {
background-color: #463770;
}
`;
function Button({ children, ...buttonProps }) {
return (
<StyledButton {...buttonProps}>
<Icon src={nailImg} alt="nail icon" />
{children}
</StyledButton>
);
}
export default Button;
자손 결합자(Descendant Combinator)로 쓴 & ${Icon} { ... } 부분을 기존 CSS로 표현해 본다면 다음과 같이 나타낼 수 있다. 버튼 안에 있는 태그 중에 Icon 컴포넌트에 해당하는 태그를 찾아서 스타일을 적용한 것이다.
.StyledButton {
...
}
.StyledButton .Icon {
margin-right: 4px;
}
특히, &와 자손 결합자를 사용하는 경우에는 &를 생략할 수 있다. 즉 ${Icon}만 써도 똑같이 동작한다. 보통 간편하게 많이 쓰니까, 자손 결합자로 Nesting 할 때는 아래처럼 쓰는 걸 권장한다.
const StyledButton = styled.button`
background-color: #6750a4;
border: none;
color: #ffffff;
padding: 16px;
${Icon} {
margin-right: 4px;
}
&:hover,
&:active {
background-color: #463770;
}
`;
참고로 Nesting은 여러 겹으로 할 수도 있다.
const StyledButton = styled.button`
...
&:hover,
&:active {
background-color: #7760b4;
${Icon} {
opacity: 0.2;
}
}
`;
&:hover, &:active { ... } 안에 있는 ${Icon} 선택자를 CSS 코드로 표현해 보면 다음과 같을 것이다.
.StyledButton:hover .Icon,
.StyledButton:active .Icon {
opacity: 0.5;
}
크기를 조절하는 size, 둥근 모양을 지정하는 round라는 Prop을 추가해 버튼 컴포넌트의 크기와 모양을 조절해 보도록 하겠다.
// Button.js
import styled from 'styled-components';
const SIZES = {
large: 24,
medium: 20,
small: 16,
};
const Button = styled.button`
background-color: #6750a4;
border: none;
border-radius: ${({ round }) => round ? `9999px` : `3px`};
color: #ffffff;
font-size: ${({ size }) => SIZES[size] ?? SIZES['medium']}px;
padding: 16px;
&:hover,
&:active {
background-color: #463770;
}
`;
export default Button;
// App.js
import styled from 'styled-components';
import Button from './Button';
const Container = styled.div`
${Button} {
margin: 10px;
}
`;
function App() {
return (
<Container>
<h1>기본 버튼</h1>
<Button size="small">small</Button>
<Button size="medium">medium</Button>
<Button size="large">large</Button>
<h1>둥근 버튼</h1>
<Button size="small" round>
round small
</Button>
<Button size="medium" round>
round medium
</Button>
<Button size="large" round>
round large
</Button>
</Container>
);
}
export default App;
템플릿 리터럴 안에는 달러와 중괄호(${ ... })를 사용해서 JavaScript 코드를 집어넣을 수 있다. 이런 걸 표현 삽입법(Expression Interpolation)이라고 부른다. 표현식 삽입법을 사용하면 Styled Components에서 Prop에 따라 컴포넌트의 스타일을 다르게 보여줄 수 있다. JSX에서 Prop이나 State에 따라 HTML 태그를 다르게 보여주는 것과 비슷하다.
가장 기본적인 사용법은 JavaScript 변수를 그대로 넣는 방식이다.
const a = 1;
const b = 2;
const str = `${a} 더하기 ${b}는 ${a + b} 입니다.`;
${SIZES['medium']} 부분은 숫자 20을 뜻하기 때문에, font-size: ${SIZES['medium']}px;는 font-size: 20px;란 코드가 된다.
const SIZES = {
large: 24,
medium: 20,
small: 16
};
const Button = styled.button`
...
font-size: ${SIZES['medium']}px;
`;
Prop에 따라 스타일을 다르게 적용하는 함수를 넣으려고 한다. 함수의 파라미터로는 Props를 받고, 리턴 값으로는 스타일 코드를 리턴하면 된다. 참고로 이건 템플릿 리터럴의 기능이 아니라 Styled Components가 내부적으로 처리해 주는 것이다.
const SIZES = {
large: 24,
medium: 20,
small: 16
};
const Button = styled.button`
...
font-size: ${(props) => SIZES[props.size]}px;
`;
// 구조 분해(Destructuring)
font-size: ${({ size }) => SIZES[size]}px;
size Prop이 값이 없거나 잘못된 값일 경우 어떻게 되는가? Styled Components에서는 undefined 값은 빈 문자열로 처리해 주기 때문에 font-size: px 같은 잘못된 CSS 코드가 된다. 그래서 가능하면 기본 값을 정해주는 게 좋다. 여러 가지 방법이 있겠지만, 널 병합 연산자(Nullish coalescing opeator)를 사용할 수 있다.
font-size: ${({ size }) => SIZES[size] ?? SIZES['medium']}px;
함수를 사용할 때 많이 사용하는 패턴 중 하나는 논리 연산자를 사용하는 것이다. 예를 들어, round라는 Prop이 참일 때 컴포넌트의 모서리를 둥글게 만드는 것이다.
const Button = styled.button`
...
${({ round }) => round && `
border-radius: 9999px;
`}
`;
round 값이 참이면 그 뒤에 값까지 계산하기 때문에 border-radius: 9999px이라는 문자열이 리턴돼서 적용된다. 반대로, round 값이 거짓이면 그냥 false가 리턴돼서 아무런 값도 적용되지 않는다. 리액트에서 JSX로 조건부 렌더링 하는 것과 비슷하다.
마찬가지로 자주 쓰는 패턴이다. round가 참이면 완전히 둥근 모서리를 보여주고, 거짓이면 3px 정도로 살짝 부드럽게 깎인 모서리를 보여주고 싶을 때 삼항 연산자로 쓸 수 있다.
border-radius: ${({ round }) => round ? `9999px` : `3px`};
Styled Components로 만들어진 컴포넌트를 상속하려면 styled() 함수를 사용하면 된다.
// Button.js
import styled from 'styled-components';
const SIZES = {
large: 24,
medium: 20,
small: 16,
};
const Button = styled.button`
background-color: #6750a4;
border: none;
color: #ffffff;
font-size: ${({ size }) => SIZES[size] ?? SIZES['medium']}px;
padding: 16px;
${({ round }) =>
round
? `
border-radius: 9999px;
`
: `
border-radius: 3px;
`}
&:hover,
&:active {
background-color: #463770;
}
`;
export default Button;
// App.js
import styled from 'styled-components';
import Button from './Button';
const SubmitButton = styled(Button)`
background-color: #de117d;
display: block;
margin: 0 auto;
width: 200px;
&:hover {
background-color: #f5070f;
}
`;
function App() {
return (
<div>
<SubmitButton>계속하기</SubmitButton>
</div>
);
}
export default App;
Button 컴포넌트의 스타일을 상속해서 새로운 버튼 SubmitButton을 만들고, App 컴포넌트 안에 SubmitButton을 배치하는 상황이다. 코드를 보면 SubmitButton 컴포넌트를 만들 때 styled(Button)이라고 작성했다. 이렇게 하면 SubmitButton이 Button의 스타일을 상속받게 된다. Button 컴포넌트에 SubmitButton의 스타일이 상속됐기 때문에, 마찬가지로 글씨는 흰색으로 보이고 있다.
상속이라는 단어가 어렵게 느껴질 수도 있다. 다른 컴포넌트의 스타일을 가져와서 원하는 대로 사용할 수 있는 것이라고 이해하면 된다. SubmitButton도 Button의 스타일 전부를 상속받고, 몇 가지 스타일만 추가해 원하는 컴포넌트를 만들고 있다.
styled.tagname으로 만든 컴포넌트는 바로 styled() 함수를 사용할 수 있지만, 그렇지 않은 컴포넌트는 따로 처리가 필요하다. 약관을 보여주는 TermsOfService라는 컴포넌트를 만든다고 가정하자.
// TermsOfService.js
function TermsOfService() {
return (
<div>
<h1>㈜코드잇 서비스 이용약관</h1>
<p>
환영합니다.
<br />
Codeit이 제공하는 서비스를 이용해주셔서 감사합니다. 서비스를
이용하시거나 회원으로 가입하실 경우 본 약관에 동의하시게 되므로, 잠시
시간을 내셔서 주의 깊게 살펴봐 주시기 바랍니다.
</p>
<h2>제 1 조 (목적)</h2>
<p>
본 약관은 ㈜코드잇이 운영하는 기밀문서 관리 프로그램인 Codeit에서
제공하는 서비스를 이용함에 있어 이용자의 권리, 의무 및 책임사항을
규정함을 목적으로 합니다.
</p>
</div>
);
}
export default TermsOfService;
// App.js
import styled from 'styled-components';
import Button from './Button';
import TermsOfService from './TermsOfService';
const StyledTermsOfService = styled(TermsOfService)`
background-color: #ededed;
border-radius: 8px;
padding: 16px;
margin: 40px auto;
width: 400px;
`;
const SubmitButton = styled(Button)`
background-color: #de117d;
display: block;
margin: 0 auto;
width: 200px;
&:hover {
background-color: #f5070f;
}
`;
function App() {
return (
<div>
<StyledTermsOfService />
<SubmitButton>계속하기</SubmitButton>
</div>
);
}
export default App;
styled()로 지정한 스타일이 적용되지 않는다. StyledTermsOfService에 지정한 배경색이랑 너비가 적용이 되지 않았다. Styled Components는 내부적으로 className을 따로 생성한다. 그리고, 자체적으로 생성된 className이 있는 부분에 styled() 함수의 스타일이 입혀진다.
그런데, JSX 문법으로 직접 만든 컴포넌트는 styled() 함수가 적용될 className에 대한 정보가 없다. styled() 함수에서 지정한 스타일이 입혀질 부분이 어딘지 알 수 없어 스타일이 적용되지 않는 것이다. 이렇게, Styled Components를 사용하지 않고 직접 만든 컴포넌트는 className 값을 Prop으로 따로 내려줘야 styled() 함수를 사용할 수 있다.
// TermsOfService.js
function TermsOfService({ className }) {
return (
<div className={className}>
...
</div>
);
}
직접 만든 컴포넌트에 className prop을 따로 내려주는 건 styled() 함수가 적용될 부분의 className을 별도로 정해주는 거라고 이해하면 된다. 위 코드의 경우엔, <div> 태그에 className을 내려줬기 때문에 styled(TermsOfService)에서 작성한 코드는 TermsOfService 안에 있는 <div> 태그에 적용된다.
정리하자면, 스타일 상속을 하려면 styled() 함수를 사용하면 되는데, styled.tagname으로 만든 컴포넌트는 styled() 함수로 바로 상속하면 되고, Styled Components를 사용하지 않고 직접 만든 컴포넌트에는 클래스 이름을 내려준 후에 styled() 함수로 상속해야 한다.
가끔 중복되는 CSS 코드들을 변수처럼 저장해서 여러 번 다시 사용하고 싶을 때 사용되는 css 함수에 대해 배워보도록 하겠다. Button 컴포넌트와 Input 컴포넌트에 같은 글자 크기를 갖도록 하는 상황을 가정하자. size라는 Prop으로 small, medium, large 각각에 지정된 크기를 전달하면 16, 20, 24 픽셀로 글자 크기를 지정하려 한다.
import styled, { css } from 'styled-components';
const SIZES = {
large: 24,
medium: 20,
small: 16
};
const fontSize = css`
font-size: ${({ size }) => SIZES[size] ?? SIZES['medium']}px;
`;
const Button = styled.button`
...
${fontSize}
`;
const Input = styled.input`
...
${fontSize}
`;
일반적인 템플릿 리터럴을 쓰는 게 아니라 css라는 태그 함수를 붙여서 쓴다. Props를 받아서 사용하는 함수가 들어있기 때문에 반드시 css 함수를 사용해야 한다.
const boxShadow = `
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
`;
함수를 삽입하지 않는 단순한 문자열이라면 일반적인 템플릿 리터럴을 써도 된다.
const boxShadow = css`
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
`;
하지만, 이런 경우에도 항상 css 함수를 사용하도록 습관화하는 것을 권장한다.