Sass(Syntatically Awesome Style Sheets)는 CSS preprocessor로서, 복잡한 작업을 쉽게 하며, 코드의 재활용성을 높여줄 뿐만 아니라, 코드의 가독성을 높여주어 유지보수를 쉽게 한다.
https://react.vlpt.us/styling/01-sass.html
리액트 프로제트에서 컴포넌트를 스타일링 할 때 CSS Module을 사용하여 CSS 클래스가 중첩되는것을 완벽히 방지할 수 있다. CRA로 만든 프로젝트에서 CSS Module을 사용할 때에는 CSS 파일의 확장자를 .module.css
로 사용한다.
https://react.vlpt.us/styling/02-css-module.html
해당 기술을 통해 JS 안에 CSS를 작성할 수 있다.
Template Literal
문자열 조합을 더욱 쉽게 할 수 있게 해주는 ES6 문법이다.
Template Literal을 사용하면서도, 그 내부에 넣은 자바스크립트 값을 조회하고 싶을 때 사용한다. styled-components에서는 이런 문법을 사용해서 컴포넌트의 props를 읽어온다.
const StyledDiv = styled`
background: ${props => props.color};
`
$ npx create-react-app styling-with-styled-components
$ cd styling-with-styled-components
$ yarn add styled-components
import styled from 'styled-components';
const Circle = styled.div`
width: 5rem;
height: 5rem;
background: ${props => props.color || 'black'};
border-radius: 50%;
`;
function App() {
return <Circle color="blue" />;
}
const Circle = styled.div`
...
${props =>
props.huge &&
css`
width: 10rem;
height: 10rem;
`}
`;
function App() {
return <Circle color="red" huge />;
}
const StyledButton = styled.button`
...
`;
function Button({ children, ...rest }) {
return <StyledButton {...rest}>{children}</StyledButton>;
}
Sass를 사용할 때에는 lighten() 또는 darken()과 같은 유틸함수를 사용하여 색상에 변화를 줄 수 있었다. CSS in JS에서도 비슷한 유틸 함수를 사용하고 싶다면 polished 라이브러리를 사용할 수 있다.
$ yarn add polished
import { darken, lighten } from 'polished';
const StyledButton = styled.button`
...
/* 색상 */
background: #228be6;
&:hover {
background: ${lighten(0.1, '#228be6')};
}
&:active {
background: ${darken(0.1, '#228be6')};
}
...
`;
function Button({ children, ...rest }) {
return <StyledButton {...rest}>{children}</StyledButton>;
}
export default Button;
styled-components로 만드는 모든 컴포넌트에서 조회하여 사용할 수 있는 전역적인 값을 설정한다. 다음과 같이 theme을 설정하면 ThemeProvider 내부에 렌더링된 styled-components 로 만든 컴포넌트에서 palette
를 조회하여 사용 할 수 있다.
// App.js
import styled, { ThemeProvider } from 'styled-components';
function App() {
return (
<ThemeProvider
theme={{
palette: {
blue: '#228be6',
gray: '#495057',
pink: '#f06595'
}
}}
>
<AppBlock>
<Button>BUTTON</Button>
// <Button color="gray">BUTTON</Button>
// <Button color="pink">BUTTON</Button>
</AppBlock>
</ThemeProvider>
);
}
export default App;
// Button.js
import styled, { css } from 'styled-components';
import { darken, lighten } from 'polished';
const StyledButton = styled.button`
...
/* 색상 */ // using destructing
${({ theme, color }) => {
const selected = theme.palette[color];
return css`
background: ${selected};
&:hover {
background: ${lighten(0.1, selected)};
}
&:active {
background: ${darken(0.1, selected)};
}
`;
}}
...
`;
function Button({ children, ...rest }) {
return <StyledButton {...rest}>{children}</StyledButton>;
}
Button.defaultProps = {
color: 'blue'
};
export default Button;
const colorStyles = css`
${({ theme, color }) => {
const selected = theme.palette[color];
return css`
background: ${selected};
&:hover {
background: ${lighten(0.1, selected)};
}
&:active {
background: ${darken(0.1, selected)};
}
`;
}}
`;
const sizes = {
large: {
height: '3rem',
fontSize: '1.25rem'
},
medium: {
height: '2.25rem',
fontSize: '1rem'
},
small: {
height: '1.75rem',
fontSize: '0.875rem'
}
};
const sizeStyles = css`
${({ size }) => css`
height: ${sizes[size].height};
font-size: ${sizes[size].fontSize};
`}
`;
const StyledButton = styled.button`
/* 공통 스타일 */
...
/* 크기 */
${sizeStyles}
/* 색상 */
${colorStyles}
/* 기타 */
& + & {
margin-left: 1rem;
}
`;
function Button({ children, color, size, ...rest }) {
return (
<StyledButton color={color} size={size} {...rest}>
{children}
</StyledButton>
);
}
Button.defaultProps = {
color: 'blue',
size: 'medium'
};
export default Button;
// components/Button.js
import React from 'react';
import styled, { css } from 'styled-components';
import { darken, lighten } from 'polished';
const colorStyles = css`
${({ theme, color }) => {
const selected = theme.palette[color];
return css`
background: ${selected};
&:hover {
background: ${lighten(0.1, selected)};
}
&:active {
background: ${darken(0.1, selected)};
}
${props =>
props.outline &&
css`
color: ${selected};
background: none;
border: 1px solid ${selected};
&:hover {
background: ${selected};
color: white;
}
`}
`;
}}
`;
const sizes = {
large: {
height: '3rem',
fontSize: '1.25rem'
},
medium: {
height: '2.25rem',
fontSize: '1rem'
},
small: {
height: '1.75rem',
fontSize: '0.875rem'
}
};
const sizeStyles = css`
${({ size }) => css`
height: ${sizes[size].height};
font-size: ${sizes[size].fontSize};
`}
`;
const fullWidthStyle = css`
${props =>
props.fullWidth &&
css`
width: 100%;
justify-content: center;
& + & {
margin-left: 0;
margin-top: 1rem;
}
`}
`;
const StyledButton = styled.button`
/* 공통 스타일 */
display: inline-flex;
outline: none;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
cursor: pointer;
padding-left: 1rem;
padding-right: 1rem;
/* 크기 */
${sizeStyles}
/* 색상 */
${colorStyles}
/* 기타 */
& + & {
margin-left: 1rem;
}
${fullWidthStyle}
`;
function Button({ children, color, size, outline, fullWidth, ...rest }) {
return (
<StyledButton
color={color}
size={size}
outline={outline}
fullWidth={fullWidth}
{...rest}
>
{children}
</StyledButton>
);
}
Button.defaultProps = {
color: 'blue',
size: 'medium'
};
export default Button;
// App.js
import React from 'react';
import styled, { ThemeProvider } from 'styled-components';
import Button from './components/Button';
const AppBlock = styled.div`
width: 512px;
margin: 0 auto;
margin-top: 4rem;
border: 1px solid black;
padding: 1rem;
`;
const ButtonGroup = styled.div`
& + & {
margin-top: 1rem;
}
`;
function App() {
return (
<ThemeProvider
theme={{
palette: {
blue: '#228be6',
gray: '#495057',
pink: '#f06595'
}
}}
>
<AppBlock>
<ButtonGroup>
<Button size="large">BUTTON</Button>
<Button>BUTTON</Button>
<Button size="small">BUTTON</Button>
</ButtonGroup>
<ButtonGroup>
<Button color="gray" size="large">
BUTTON
</Button>
<Button color="gray">BUTTON</Button>
<Button color="gray" size="small">
BUTTON
</Button>
</ButtonGroup>
<ButtonGroup>
<Button color="pink" size="large">
BUTTON
</Button>
<Button color="pink">BUTTON</Button>
<Button color="pink" size="small">
BUTTON
</Button>
</ButtonGroup>
<ButtonGroup>
<Button size="large" outline>
BUTTON
</Button>
<Button color="gray" outline>
BUTTON
</Button>
<Button color="pink" size="small" outline>
BUTTON
</Button>
</ButtonGroup>
<ButtonGroup>
<Button size="large" fullWidth>
BUTTON
</Button>
<Button size="large" color="gray" fullWidth>
BUTTON
</Button>
<Button size="large" color="pink" fullWidth>
BUTTON
</Button>
</ButtonGroup>
</AppBlock>
</ThemeProvider>
);
}
export default App;
styled-components 에서도 Nested CSS 문법을 사용 할 수 있기 때문에 DialogBlock 안에 있는 h3 와 p 에게 특정 스타일을 주고 싶다면 다음과 같이 작성 할 수 있다.
const DialogBlock = styled.div`
h3 {}
p {}
`;
// Compenents/Dialog.js
import React from 'react';
import styled from 'styled-components';
import Button from './Button';
const DarkBackground = styled.div`
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
`;
const DialogBlock = styled.div`
width: 320px;
padding: 1.5rem;
background: white;
border-radius: 2px;
h3 {
margin: 0;
font-size: 1.5rem;
}
p {
font-size: 1.125rem;
}
`;
const ButtonGroup = styled.div`
margin-top: 3rem;
display: flex;
justify-content: flex-end;
`;
const ShortMarginButton = styled(Button)`
& + & {
margin-left: 0.5rem;
}
`;
function Dialog({
title,
children,
confirmText,
cancelText,
onConfirm,
onCancel,
visible
}) {
if (!visible) return null;
return (
<DarkBackground>
<DialogBlock>
<h3>{title}</h3>
<p>{children}</p>
<ButtonGroup>
<ShortMarginButton color="gray" onClick={onCancel}>
{cancelText}
</ShortMarginButton>
<ShortMarginButton color="pink" onClick={onConfirm}>
{confirmText}
</ShortMarginButton>
</ButtonGroup>
</DialogBlock>
</DarkBackground>
);
}
Dialog.defaultProps = {
confirmText: '확인',
cancelText: '취소'
};
export default Dialog;
// App.js
import React, { useState } from 'react';
import styled, { ThemeProvider } from 'styled-components';
import Button from './components/Button';
import Dialog from './components/Dialog';
const AppBlock = styled.div`
width: 512px;
margin: 0 auto;
margin-top: 4rem;
border: 1px solid black;
padding: 1rem;
`;
const ButtonGroup = styled.div`
& + & {
margin-top: 1rem;
}
`;
function App() {
const [dialog, setDialog] = useState(false);
const onClick = () => {
setDialog(true);
};
const onConfirm = () => {
console.log('확인');
setDialog(false);
};
const onCancel = () => {
console.log('취소');
setDialog(false);
};
return (
<ThemeProvider
theme={{
palette: {
blue: '#228be6',
gray: '#495057',
pink: '#f06595'
}
}}
>
<>
<AppBlock>
<ButtonGroup>
<Button size="large">BUTTON</Button>
<Button>BUTTON</Button>
<Button size="small">BUTTON</Button>
</ButtonGroup>
<ButtonGroup>
<Button color="gray" size="large">
BUTTON
</Button>
<Button color="gray">BUTTON</Button>
<Button color="gray" size="small">
BUTTON
</Button>
</ButtonGroup>
<ButtonGroup>
<Button color="pink" size="large">
BUTTON
</Button>
<Button color="pink">BUTTON</Button>
<Button color="pink" size="small">
BUTTON
</Button>
</ButtonGroup>
<ButtonGroup>
<Button size="large" outline>
BUTTON
</Button>
<Button color="gray" outline>
BUTTON
</Button>
<Button color="pink" size="small" outline>
BUTTON
</Button>
</ButtonGroup>
<ButtonGroup>
<Button size="large" fullWidth>
BUTTON
</Button>
<Button size="large" color="gray" fullWidth>
BUTTON
</Button>
<Button size="large" color="pink" fullWidth onClick={onClick}>
삭제
</Button>
</ButtonGroup>
</AppBlock>
<Dialog
title="정말로 삭제하시겠습니까?"
confirmText="삭제"
cancelText="취소"
onConfirm={onConfirm}
onCancel={onCancel}
visible={dialog}
>
데이터를 정말로 삭제하시겠습니까?
</Dialog>
</>
</ThemeProvider>
);
}
export default App;
styled-compenents에서 CSS keyframe을 사용하기 위해서 keyframes라는 유틸을 사용한다. Dialog가 나타날 때: DarkBackground 쪽에는 서서히 나타나는 fadeIn 효과를 주고, DialogBlock에는 아래에서부터 위로 올라오는 효과를 보여주는 slideUp 효과를 주도록 하자.
import React from 'react';
import styled, { keyframes } from 'styled-components';
import Button from './Button';
const fadeIn = keyframes`
from {
opacity: 0
}
to {
opacity: 1
}
`;
const slideUp = keyframes`
from {
transform: translateY(200px);
}
to {
transform: translateY(0px);
}
`;
const DarkBackground = styled.div`
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
animation-duration: 0.25s;
animation-timing-function: ease-out;
animation-name: ${fadeIn};
animation-fill-mode: forwards;
`;
const DialogBlock = styled.div`
width: 320px;
padding: 1.5rem;
background: white;
border-radius: 2px;
h3 {
margin: 0;
font-size: 1.5rem;
}
p {
font-size: 1.125rem;
}
animation-duration: 0.25s;
animation-timing-function: ease-out;
animation-name: ${slideUp};
animation-fill-mode: forwards;
`;
const ButtonGroup = styled.div`
margin-top: 3rem;
display: flex;
justify-content: flex-end;
`;
const ShortMarginButton = styled(Button)`
& + & {
margin-left: 0.5rem;
}
`;
function Dialog({
title,
children,
confirmText,
cancelText,
onConfirm,
onCancel,
visible
}) {
if (!visible) return null;
return (
<DarkBackground>
<DialogBlock>
<h3>{title}</h3>
<p>{children}</p>
<ButtonGroup>
<ShortMarginButton color="gray" onClick={onCancel}>
{cancelText}
</ShortMarginButton>
<ShortMarginButton color="pink" onClick={onConfirm}>
{confirmText}
</ShortMarginButton>
</ButtonGroup>
</DialogBlock>
</DarkBackground>
);
}
Dialog.defaultProps = {
confirmText: '확인',
cancelText: '취소'
};
export default Dialog;
Dialog 컴포넌트에서 두개의 로컬 상태를 관리해주어 한다. 하나는 현재 트랜지션 효과를 보여주고 있는 중이라는 상태를 의미하는 animate, 나머지 하나는 실제로 컴포넌트가 사라지는 시점을 지연시키기 위한 localVisible 값이다.
useEffect를 하나 작성해주어야 하는데, visible 값이 true에서 false로 바뀌는 시점을 감지하여 animate 값을 true로 바꿔주고 setTimeout 함수를 사용하여 250ms 이후 false로 바꾸어 주어야 한다. 추가적으로 !visible 조건에서 null을 반환하는 대신에 !animate && !localVisible 조건에서 null을 반환하도록 수정해주어야 한다.
import React, { useState, useEffect } from 'react';
import styled, { keyframes, css } from 'styled-components';
import Button from './Button';
const fadeIn = keyframes`
from {
opacity: 0
}
to {
opacity: 1
}
`;
const fadeOut = keyframes`
from {
opacity: 1
}
to {
opacity: 0
}
`;
const slideUp = keyframes`
from {
transform: translateY(200px);
}
to {
transform: translateY(0px);
}
`;
const slideDown = keyframes`
from {
transform: translateY(0px);
}
to {
transform: translateY(200px);
}
`;
const DarkBackground = styled.div`
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
animation-duration: 0.25s;
animation-timing-function: ease-out;
animation-name: ${fadeIn};
animation-fill-mode: forwards;
${props =>
props.disappear &&
css`
animation-name: ${fadeOut};
`}
`;
const DialogBlock = styled.div`
width: 320px;
padding: 1.5rem;
background: white;
border-radius: 2px;
h3 {
margin: 0;
font-size: 1.5rem;
}
p {
font-size: 1.125rem;
}
animation-duration: 0.25s;
animation-timing-function: ease-out;
animation-name: ${slideUp};
animation-fill-mode: forwards;
${props =>
props.disappear &&
css`
animation-name: ${slideDown};
`}
`;
const ButtonGroup = styled.div`
margin-top: 3rem;
display: flex;
justify-content: flex-end;
`;
const ShortMarginButton = styled(Button)`
& + & {
margin-left: 0.5rem;
}
`;
function Dialog({
title,
children,
confirmText,
cancelText,
onConfirm,
onCancel,
visible
}) {
const [animate, setAnimate] = useState(false);
const [localVisible, setLocalVisible] = useState(visible);
useEffect(() => {
// visible 값이 true -> false 가 되는 것을 감지
if (localVisible && !visible) {
setAnimate(true);
setTimeout(() => setAnimate(false), 250);
}
setLocalVisible(visible);
}, [localVisible, visible]);
if (!animate && !localVisible) return null;
return (
<DarkBackground disappear={!visible}>
<DialogBlock disappear={!visible}>
<h3>{title}</h3>
<p>{children}</p>
<ButtonGroup>
<ShortMarginButton color="gray" onClick={onCancel}>
{cancelText}
</ShortMarginButton>
<ShortMarginButton color="pink" onClick={onConfirm}>
{confirmText}
</ShortMarginButton>
</ButtonGroup>
</DialogBlock>
</DarkBackground>
);
}
Dialog.defaultProps = {
confirmText: '확인',
cancelText: '취소'
};
export default Dialog;