원문 : building-a-design-system-from-scratch/
지속 가능한 UI를 구축하기 위한 헥심은 열쇠는
색상과 타이포그래피에 대한 명확한 표준을 설정하고
재사용 가능한 컴포넌트를 구축하는 것입니다.
전체 코드 보기 : @maximeheckel/design-system
왜 디자인 시스템을 구축하나요?
- 브랜딩
- FE 엔지니어로 자신을 브랜딩
- 회사의 아이덴티티
- 일관성
- 재미/학습
- 디자인 시스템적 사고 방식
- 컴포넌트 구축 역량
토큰
컬러 시스템
- 첫 번째 레이어는 같은 팔레트 내 다양한 색상의 HSL(Hue, Saturation, Lightness) 값을 나타내는 일련의 변수입니다.
- --blue-10: 222, 89%, 90% 또는 --red-60: 0, 95%, 40%.
- 두 번째 레이어는 디자인 시스템의 구성 요소에서 참조하게 될 색상에 대한 일반적인 별칭입니다.
- --brand: hsl(var(--blue-50)) 또는 --foreground: hsla(var(- -gray-05), 60%).
- 이 레이어에서는 첫 번째 레이어에서 정의한 색상을 사용하여 구성하거나 확장합니다.
- 컴포넌트는 변경에 더 탄력적입니다.
- Button 구성 요소의 배경색은 --blue-10이 아니라 --brand이며 해당 변수의 값은 시간이 지남에 따라 파란색에서 보라색 또는 다른 것으로 진화할 수 있습니다.
- 브랜드 색상을 변경하고 싶으십니까?
- --brand 변수의 값을 업데이트하기만 하면 이를 참조하는 모든 컴포넌트가 그에 따라 업데이트됩니다. 불투명도를 추가하는 것과 같이 색상 토큰을 구성할 수 있습니다
- 라이트 모드와 다크 모드와 같은 테마를 쉽게 구축할 수 있습니다.
- 라이트 모드에서 --brand는 --blue-60을 참조할 수 있고
- 다크 모드에서는 --blue-20을 참조할 것입니다.
1. 베이스 색상을 고르고
2. 밝기를 증가/감소시켜 각 색상에 대한 스케일을 생성합니다.
3단계: 라이트 모드와 다크 모드에 대한 색상 하위 집합을 선택하고 이름을 지정합니다.
다른 토큰들
스페이싱
--space-0: 0px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 40px;
--space-8: 48px;
--space-9: 56px;
--space-10: 64px;
--space-11: 80px;
--space-12: 96px;
글꼴 관련 토큰
--font-size-1: 0.75rem;
--font-size-2: 0.875rem;
--font-size-3: 1rem;
--font-size-4: 1.125rem;
--font-size-5: 1.25rem;
--font-size-6: 1.5rem;
--font-size-7: 2rem;
반지름 토큰
--border-radius-0: 4px;
--border-radius-1: 8px;
--border-radius-2: 16px;
네이밍
다음은 내가 직접 따랐던 몇 가지 규칙을 요약한 것입니다.
- "크기 관련" 토큰 세트의 경우 1, 2, ..., 12와 같이 1씩 증가하는 숫자 접미사를 사용하도록 선택합니다.
- 색상 스케일과 같이 앞으로 좀 더 추가할 세분성이 필요할 수 있는 토큰의 경우 10씩 증가하는 숫자 접미사를 선택했습니다.
Lesson Learned
디자인 토큰도 진화합니다.
- 디자이너가 없었습니다.
- 디자인을 직감이나 시행 착오에만 의존할 수 있었습니다.
- 가능한 한 많은 토큰을 포함하려 했습니다.
- 디자인 시스템의 복잡성과 일관성 수준 사이의 균형을 유지하는 것은 어려웠습니다.
Component patterns
- 좋은 개발자 경험(DX)
- 컴포넌트가 유용하고 더 빠르게 작업하고 실험하고 반복하는 데 도움이 되도록 합니다
- 응집력 있는 디자인/디자인 언어
- 컴포넌트를 코드 측면뿐만 아니라 시각적으로도 합성할 수 있습니다.
Variant driven components
@stitches/react를 사용합니다. 높은 수준의 DX를 제공합니다.- The variant-driven approach
- Stitches는 variant의 사용을 강조합니다.
- 해당 컴포넌트가 지원하는 variant 세트는 미리 정의되어야 합니다.
- 즉, 스타일링에 동적 props는 허용되지 않습니다.
- 저는 디자인 시스템에서 작업할 때 이 패턴을 크게 신뢰합니다. 개발자 경험과 컴포넌트의 인터페이스에 대해 정말로 생각하게 만듭니다.
- 합성, 컴파운드 컴포넌트의 이점을 누리기 위해 variant의 수를 관리합니다.
- 즉, 스타일링에 동적 props는 허용되지 않습니다.
- 다형성 지원
- 다형성(polymorphic as props) as prop을 통해 태그를 재정의 할 수 있습니다.
- 다형성(polymorphic as props) as prop을 통해 태그를 재정의 할 수 있습니다.
- 고급 Typescript 지원
- variatne는 자동으로 타입과 함께 제공됩니다. 추가 작업이 필요하지 않습니다.
- variatne는 자동으로 타입과 함께 제공됩니다. 추가 작업이 필요하지 않습니다.
variant를 이용한 예제입니다.
import { styled } from '@stitches/react';
const Block = styled('div', {
borderRadius: 8px;
height: '50px';
width: '100%';
display: 'flex';
justifyContent: 'center;
alignItems: 'center';
variants: {
/* the appearance prop will be automatically typed as 'primary' | 'secondary' */
appearance: {
'primary': {
background: 'blue';
color: 'white';
},
'secondary': {
background: 'hotpink';
color: 'white';
}
}
}
/* specifying a default variant will make the appearance prop optional */
defaultVariant: {
appearance: 'primary';
}
});
const App = () => {
return (
<Block as="section" appearance="secondary">
Styled-components
</Block>
)
}
컴포넌트 개발 시 반복하는 하나의 패턴은
transiton 및 hover/focus/active 상태를 처리하기 위해 로컬 CSS 변수에 의존하는 것이었습니다.
아래의 코드로 해결해 봅시다.
import { styled } from '@stitches/react';
const StyledButton = styled('button', {
/* Initializing local variables first and assigning them default values */
background: 'var(--background, white)',
color: 'var(--color, black)',
boxShadow: 'var(--shadow, none)',
opacity: 'var(--opacity, 1)',
transform: 'scale(var(--button-scale, 1)) translateZ(0)',
/* Main styles of the component */
padding: 'var(--space-3) var(--space-4)',
fontSize: 'var(--font-size-2)',
fontWeight: 'var(--font-weight-3)',
height: '44px',
width: 'max-content',
transition: 'background 0.2s, transform 0.2s, color 0.2s, box-shadow 0.3s',
borderRadius: 'var(--border-radius-1)',
/* Update local variables based on state/variant */
'&:active': {
'--button-scale': 0.95,
},
'&:disabled': {
'--background': 'var(--form-input-disabled)',
'--color': 'var(--typeface-tertiary)',
},
'&:hover': {
'&:not(:disabled)': {
'--shadow': 'var(--shadow-hover)',
},
},
'&:focus-visible': {
'--shadow': 'var(--shadow-hover)',
},
variants: {
variant: {
primary: {
'--background': 'var(--brand)',
'--color': 'var(--typeface-primary)',
},
secondary: {
'--background': 'var(--brand-transparent)',
'--color': 'var(--brand)',
},
},
},
});
- 해당 컴포넌트에서 사용되는 로컬 변수는 맨 위에 있습니다. 여기에서 기본값으로 초기화합니다.
- 다음엔 모든 주요 CSS 속성을 포함하는 CSS의 본문을 작업합니다.
- 다음엔 모든 중첩 코드, variant, selector가 위치합니다. ::before 또는 ::after 문은 해당 CSS 변수만 재할당합니다.
Stitches의 테마와 변수 시스템을 활용하고 있지 않다는 것을 알 수 있습니다.
내 의도는 내 코드를 어떤 프레임워크/라이브러리와도 독립적으로 만드는 것이었습니다.
Utility components (유틸리티 컴포넌트)
이 디자인 시스템의 목적은 더 빠른 작업/실험을 가능하게 하는 것이기 때문에,
유틸리티 컴포넌트 세트를 생각해 냈습니다.
이러한 컴포넌트의 범위는 다음과 같습니다.
BOX
import { styled } from '@stitches/react';
const Box = styled('div', {});
/* Usage with `css` prop on the fly */
const App = () => {
return (
<Box
css={{
background: 'var(--brand-transparent)';
color: 'var(--typeface-primary)';
borderRadius: 'var(--border-radius-1)';
width: 100,
height: 100,
}}
/>
)
}
Flex & Grid
const App = () => {
return (
<>
<Flex
alignItems="center"
direction="column"
justifyContent="center"
gap="2"
>
<Box css={...} />
<Box css={...} />
</Flex>
<Grid columns="2" gap="4">
<Box css={...} />
<Box css={...} />
<Box css={...} />
<Box css={...} />
</Grid>
</>
);
};
Text
const App = () => {
return (
<>
<Text outline size="6">
Almost before we knew it,
we had left the ground.
</Text>
<Text truncate>
Almost before we knew it,
we had left the ground.
</Text>
<Text
gradient
css={{
backgroundImage:
'linear-gradient(...)',
}}
size="6"
weight="4"
>
Almost before we knew it,
we had left the ground.
</Text>
</>
);
VisibilityHidden
https://www.joshwcomeau.com/snippets/react-components/visually-hidden/
원문에 예제가 없어서 다른 곳의 링크로 대체함
Compound components
나는 compound components를 좋아합니다.
좋은 컴파운드 컴포넌트 세트를 생각해내는 것이
컴포넌트의 DX를 크게 향상시킬 수 있다고 믿습니다.
- 더 작은 컴포넌트로 만들지 않으면 props가 오버로드(중복)되는 경우
- 컴포넌트를 여러 방법으로 합성할 수 있는 경우
Radio
<Radio.Group name="options" direction="vertical" onChange={...}>
<Radio.Item
id="option-1"
value="option1"
aria-label="Option 1"
label="Option 1"
/>
<Radio.Item
id="option-2"
value="option2"
aria-label="Option 2"
label="Option 2"
checked
/>
</Radio.Group>
Card
<Card>
<Card.Header>Title of the card</Card.Header>
<Card.Body>Content of the card</Card.Body>
</Card>
export function isElementOfType(element, ComponentType): element {
return element?.type?.displayName === ComponentType.displayName;
}
import RadioItem from './RadioItem';
const RadioGroup = (props) => {
const { children, ... } = props;
const filteredChildren = React.Children.toArray(children).filter((child) =>
isElementOfType(child, RadioItem);
);
return (
<Flex gap={2} role="radiogroup">
{filteredChildren}
</Flex>
)
}
다형성과 합성 (as를 이용한 다형성과 합성)
// Renders a p tag
<Text as="p">Hello</Text>
// Renders an h1 tag
<Text as="h1">Hello</Text>
<Card>
{/* Card.Body inherits the style, the props and the type of Flex! */}
<Card.Body as={Flex} direction="column" gap="2">
...
</Card.Body>
</Card>
위의 코드 조각은 Flex 컴포넌트로 렌더링한 Card.Body 컴파운드 컴포넌트를 보여줍니다.
이 경우 Card.Body는 스타일을 상속할 뿐만 아니라 props와 type도 상속합니다! 🤯
const DEFAULT_TAG = 'h1';
const Heading = () => {
// Remapping the size prop from Text to a new scale for Heading
const headingSize = {
1: { '@initial': '4' },
2: { '@initial': '5' },
3: { '@initial': '6' },
4: { '@initial': '7' },
};
// Overriding some styles of Text based on the new size prop of Heading
const headingCSS = {
1: {
fontWeight: 'var(--font-weight-4)',
lineHeight: '1.6818',
letterSpacing: '0px',
marginBottom: '1.45rem',
},
2: {
fontWeight: 'var(--font-weight-4)',
lineHeight: '1.6818',
letterSpacing: '0px',
marginBottom: '1.45rem',
},
3: {
fontWeight: 'var(--font-weight-4)',
lineHeight: '1.6818',
letterSpacing: '0px',
marginBottom: '1.45rem',
},
4: {
fontWeight: 'var(--font-weight-4)',
lineHeight: '1.6818',
letterSpacing: '0px',
marginBottom: '1.45rem',
},
};
return (
<Text
as={DEFAULT_TAG}
{...rest}
ref={ref}
size={headingSize[size]}
css={{
...merge(headingCSS[size], props.css),
}}
/>
);
};
// Creating a more abstracted version of Heading
const H1 = (props) => <Heading {...props} as="h1" size="4" />;
const H2 = (props) => <Heading {...props} as="h2" size="3" />;
const H3 = (props) => <Heading {...props} as="h3" size="2" />;
const H4 = (props) => <Heading {...props} as="h4" size="1" />;
위 코드는 실제로 실행되는지 나중에 확인해봐야 할 듯...
Make it shine!
Packaging and shipping
디자인 시스템의 배포 관련한 내용입니다.
- 패키징 패턴
- 파일 구조
- 빌드, 릴리즈
버저닝
- major : 중요한 디자인 토큰 변경이 발생하거나 코드의 주요 변경 사항이 발생할 때
- minor : 디자인 시스템에 새 컴포넌트 또는 새 토큰이 추가될 때
- patch : 기존 컴포넌트/토큰이 업데이트되거나 수정 사항(fix)이 제공될 때 패치
File structure
Delightful React File/Directory Structure를 참고하세요
Bundling
const esbuild = require('esbuild');
const packagejson = require('./package.json');
const { globPlugin } = require('esbuild-plugin-glob');
const sharedConfig = {
loader: {
'.tsx': 'tsx',
'.ts': 'tsx',
},
outbase: './src',
bundle: true,
minify: true,
jsxFactory: 'createElement',
jsxFragment: 'Fragment',
target: ['esnext'],
logLevel: 'debug',
external: [...Object.keys(packagejson.peerDependencies || {})],
};
esbuild
.build({
...sharedConfig,
entryPoints: ['src/index.ts'],
outdir: 'dist/cjs',
format: 'cjs',
banner: {
js: "const { createElement, Fragment } = require('react');\n",
},
})
.catch(() => process.exit(1));
esbuild
.build({
...sharedConfig,
entryPoints: [
'src/index.ts',
'src/components/**/index.tsx',
'src/lib/stitches.config.ts',
'src/lib/globalStyles.ts',
],
outdir: 'dist/esm',
splitting: true,
format: 'esm',
banner: {
js: "import { createElement, Fragment } from 'react';\n",
},
plugins: [globPlugin()],
})
.catch(() => process.exit(1));
다음은 이 구성의 주요 내용입니다.
- esbuild는 Babel과 같은 JSX 변환 기능이나 플러그인을 제공하지 않습니다. 해결 방법으로 jsxFactory(L13-14) 및 jsxFragment 옵션을 정의해야 했습니다.
- 같은 메모에서 배너 옵션을 통해 react import/require 문도 추가해야 했습니다.
- 가장 우아한 것은 아니지만 이것이 내가 이 패키지를 작동하게 할 수 있는 유일한 방법입니다.
- 이 패키지를 ESM 및 CJS 포맷으로 묶었습니다.
- ESM은 트리 쉐이킹을 지원하므로 여러 entryPoint(L35-40)를 볼 수 있습니다.
{
"scripts": {
"build": "node ./esbuild.build.js",
...
"postbuild": "yarn ts-types",
...
"ts-types": "tsc --emitDeclarationOnly --outDir dist",
}
}
// This will make the project's bundle *slightly* heavier
import { Button } from '@maximeheckel/design-system';
// This will make the project's bundle *much* heavier
import { Button, Flex, Grid, Icon, Text } from '@maximeheckel/design-system';
참고 :
나무위키 다크모드 : (나무위키 다크모드)
프로그램 가능하며 사실적인 레이어드 섀도우 시스템 : (The programmatic and realistic layered shadow system)
디자인 시스템 버전 관리: 단일 라이브러리 또는 개별 컴포넌트 : (Design System versioning: single library or individual components?)
'FrontEnd' 카테고리의 다른 글
몽고db 문법으로 불변성을 관리하는 immutability-helper (0) | 2022.07.05 |
---|---|
[짤막글] styled-components는 어떻게 스타일을 적용하는가 (0) | 2022.07.03 |
CSS Variables를 이용하여 컬러 팔레트 구성하기 (0) | 2022.07.02 |
[Next.js 튜토리얼] JS에서 리액트로 (0) | 2022.07.01 |
웹 애플리케이션을 구성하는 요소들 (0) | 2022.07.01 |