🗂

Atomic Design × Typescript × React × Muiの実践的なフロントエンド開発

2023/04/09に公開

こんにちは!BrunchMade@はせがわです。
この度、株式会社BrunchMadeに創業メンバーとしてジョインしました🙌
今回はそのBrunchMadeのホームページ制作にあたって、学んできたことを共有していきます。

はじめに

フロントエンド開発している方はほとんど聞いたことあるであろう「Atomic Design」。
そもそもAtomic Designはデザインシステムを作成するための方法論ということもあり、UI設計の答えではありません。そのため、実際のフロントエンド開発に取り入れようとすると意外と手こずることが多いのではないのでしょうか?
また、記事・サイトなどを検索しても説明は載っているが、具体的なコードが少ない。Organismsの例として提示するUIはHeaderとFooterと説明しているサイト多すぎでは等悩んだことはありませんか?(私は悩みまくりました😂)
本稿では、Typescript・Next.js(React)・Muiを使って制作されたBrunchMadeのホームページを見本に、具体的なコードも交えて共有していきたいと思います。
1つの設計案として見ていただけると幸いです🙇‍♂️

Atomic Designについて

BrunchMadeのホームページ制作は下記項目を重視してAtomic Designを採用しました。

ユーザー(訪問者)視点

  • UIに一貫性や統一感を持たせ、ユーザーの混乱を減らし、情報をより簡単に集めやすくすることで、UXの向上を図る。

開発者視点

  • 誰が使用しても同じUIが確実に作成できること。
  • 新しいリクエスト(新規機能や修正等)が来るたびに車輪を再発明するのではなく、既に確立されたUIをパズルのピースの様に再利用して、新しいページと機能追加を迅速に対応できる。
  • チーム全体の共通言語を確立し、コミュニケーションコストを減らす。

Atomic Designの公式サイトにある「Pitching patterns」を参考
https://atomicdesign.bradfrost.com/chapter-4/#pitching-patterns

Atoms

Buttonコンポーネント


責任:ユーザーのクリックに対してリアクションがあるUI

Button.tsx
import { Button as MuiButton, ButtonProps, styled } from '@mui/material';
import { memo } from 'react';

type BorderRadiusSize = 'sm' | 'md';

const borderRadiusType: Record<BorderRadiusSize, number> = {
  sm: 0.5,
  md: 2,
};

interface StyledButtonProps {
  arrow: boolean;
  borderRadiusSize: BorderRadiusSize;
}

const arrowStyle: React.CSSProperties = {
  content: '""',
  position: 'absolute',
  top: '50%',
  right: '1rem',
  width: 6,
  height: 6,
  borderTop: 'solid 1px currentColor',
  borderRight: 'solid 1px currentColor',
  transform: 'translateY(-50%) rotate(45deg)',
};

const StyledButton = styled(MuiButton, {
  shouldForwardProp: (prop) => prop !== 'arrow' && prop !== 'borderRadiusSize',
})<StyledButtonProps>(({ theme, arrow, borderRadiusSize }) => ({
  borderRadius: theme.spacing(borderRadiusType[borderRadiusSize]),
  ':after': arrow ? arrowStyle : undefined,
}));

interface Props {
  color?: 'primary' | 'secondary' | 'neutral';
  variant?: Exclude<ButtonProps['variant'], 'text'>;
  arrow?: boolean;
  borderRadiusSize?: BorderRadiusSize;
  fullWidth?: ButtonProps['fullWidth'];
  children: ButtonProps['children'];
  onClick: ButtonProps['onClick'];
}

export const Button: React.FC<Props> = memo(
  ({ color, arrow = false, borderRadiusSize = 'sm', 
     variant = 'contained', fullWidth, children, onClick 
  }) => {
    return (
      <StyledButton
        color={color}
        arrow={arrow}
        borderRadiusSize={borderRadiusSize}
        variant={variant}
        disableElevation
        fullWidth={fullWidth}
        onClick={onClick}
      >
        {children}
      </StyledButton>
    );
  },
);

ポイント

  • 不要なpropsは渡せないようにする。
    誰が使用しても同じUIが作成されるようにするため、不要なpropsは渡せないよう制御しています。
    muiのButtonコンポーネントのpropsにあるcolorとvariantはデフォルトで渡せる値が決まっていますが、下記Props型のように渡せる値を絞り込んでいます。
    制御方法は色々あると思いますが、個人的にはユニオン型を自分で定義したり、Excludeで型を取り除いたりしています。(間違った指定をした時にきちんと型エラーが出るようになっていれば問題ありません。)
interface Props {
  color?: 'primary' | 'secondary' | 'neutral';
  variant?: Exclude<ButtonProps['variant'], 'text'>; 
  ...
}
  • borderRadiusのサイズ指定を統一
    borderRadiusのサイズですが、React.CSSProperties['borderRadius']を直接Props型に指定してサイズを渡すことは可能ですが、開発者によってサイズがバラバラになってしまう恐れがあります。
    そのため、下記のようにBorderRadiusSize型を作成し、サイズの統一を図ります。
    例:smの時はtheme.spacing(0.5),mdの時はtheme.spacing(2)
    もし、サイズを増やしたい場合はBorderRadiusSize型にlg,xsなど増やすことも可能です。
type BorderRadiusSize = 'sm' | 'md';

const borderRadiusType: Record<BorderRadiusSize, number> = {
  sm: 0.5,
  md: 2,
};

Molecules

SecctionTitleコンポーネント


責任:タイトルとサブタイトルがセットで、テキストカラーが白黒で構成されるUI

SecctionTitle.tsx
import { Stack, Typography } from '@mui/material';
import { memo } from 'react';

interface Props {
  title: string;
  subTitle: string;
  isWhiteColor?: boolean;
}

export const SectionTitle: React.FC<Props> = memo(
({ title, subTitle, isWhiteColor }) => {
  return (
    <Stack alignItems='center'>
      <Typography
        variant='h2'
        color={(theme) => (isWhiteColor ? theme.palette.grey[50] : undefined)}
      >
        {title}
      </Typography>
      <Typography
        color={(theme) => (isWhiteColor ? theme.palette.grey[50] : undefined)}
        variant='body2'
      >
        {subTitle}
      </Typography>
    </Stack>
  );
});

ポイント

  • テキストカラーの指定方法
    テキストカラーも統一感を出すためにpropsのisWhiteColorで判別するようにしました。
    isWhiteColorが指定された時は白(theme.palette.grey[50])、指定されていない時は黒(muiのTypographyのデフォルト値)で統一しています。

Organisms

ViewMoreContainerコンポーネント



責任:画像とコンテンツがセットで、「詳細を見る」に促すコンテナUI

ViewMoreContainer.tsx
import { ButtonProps, Stack, styled, Typography } from '@mui/material';
import Image, { ImageProps } from 'next/image';
import { memo } from 'react';

import { Button } from '@/components/atoms/button';
import { SectionTitle } from '@/components/molecules/sectionTitle';

const Wrapper = styled('div')<{ direction: 'row' | 'row-reverse' }>(
({ theme, direction }) => ({
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  gap: theme.spacing(8),
  flexDirection: direction,
  [theme.breakpoints.down('sm')]: {
    flexDirection: 'column',
  },
}));

const TextWrapper = styled('div')(({ theme }) => ({
  margin: theme.spacing(3.5, 0, 1.5),
  textAlign: 'center',
  whiteSpace: 'break-spaces',
}));

const StyledImage = styled(Image)(({ theme }) => ({
  width: 360,
  height: 270,
  [theme.breakpoints.down('sm')]: {
    display: 'none',
  },
}));

interface Props {
  title: string;
  subTitle: string;
  description: string;
  image: {
    src: ImageProps['src'];
    alt: ImageProps['alt'];
    width: ImageProps['width'];
    height: ImageProps['height'];
  };
  direction?: 'row' | 'row-reverse';
  onClick: ButtonProps['onClick'];
}

export const ViewMoreContainer: React.FC<Props> = memo(
  ({ title, subTitle, description, image, direction = 'row', onClick }) => {
    return (
      <Wrapper direction={direction}>
        <Stack width={270} alignItems='center'>
          <SectionTitle title={title} subTitle={subTitle} />
          <TextWrapper>
            <Typography>{description}</Typography>
          </TextWrapper>
          <Button color='neutral' arrow borderRadiusSize='md' onClick={onClick}>
            詳細を見る
          </Button>
        </Stack>
        <StyledImage 
	  src={image.src} 
	  alt={image.alt} 
	  width={image.width} 
	  height={image.height} 
	/>
      </Wrapper>
    );
  },
);

ポイント

  • directionで横並びの順番を切り替える
    directionで切り替えできることでUIのレイアウトは変わりますが、責任は変わらないのでpropsで切り替えれるようにしました。
    また、Buttonでも説明している通り、React.CSSProperties['flexDirection']をそのまま指定するのではなく'row' | 'row-reverse'と絞り込んだ実装にして統一感を図ります。
  • Moleculesとの違い
    よくOrganismsとMoleculesの違いがわかりづらいという記事を目にします。
    ViewMoreContainerコンポーネントでは、下記の観点からOrganismsに選定しています。
    • Atoms(Button)とMolecules(SectionTitle)を使用している
    • 「詳細を見る」ボタンのデザインと画像とコンテンツのレイアウト(横並び)が確定している
    • BrunchMadeのホームページでしか使用されない(可能性が高い)

Templates

HomeTemplateコンポーネント

責任:ホームページのレイアウト

HomeTemplate.tsx
import { Container, Fade, styled } from '@mui/material';
import { useRouter } from 'next/router';
import { memo, useCallback } from 'react';

import background from '@/assets/background_1.png';
import meeting from '@/assets/meeting.jpg';
import nakatani from '@/assets/nakatani_2.jpg';
import backgroundSp from '@/assets/top_background_sp.png';
import { useScrollTrigger } from '@/hooks/useScrollTrigger';
import { ABOUT_US_PATH, SERVICE_PATH } from '@/utils/constants';

import { BackgroundImage } from '../molecules/backgroundImage';
import { CompanyProfile } from '../organisms/companyProfile';
import { Contact } from '../organisms/contact';
import { HeroHeader } from '../organisms/heroHeader';
import { ServiceContent } from '../organisms/serviceContent';
import { ViewMoreContainer } from '../organisms/viewMoreContainer';

const Main = styled('main')({
  position: 'relative',
});

const ServiceWrapper = styled(Container)(({ theme }) => ({
  marginBottom: '-7.25vh',
  [theme.breakpoints.up('xl')]: {
    marginTop: '35vh',
    marginBottom: '-6.25vh',
  },
  [theme.breakpoints.down('sm')]: {
    marginTop: theme.spacing(6),
    marginBottom: 0,
  },
}));

const AboutUsWrapper = styled('div')(({ theme }) => ({
  position: 'relative',
  [theme.breakpoints.down('sm')]: {
    marginTop: theme.spacing(1),
  },
}));

export const HomeTemplate: React.FC = memo(() => {
  const router = useRouter();

  const { ref: serviceRef, isTriggered: isServiceVisible } = useScrollTrigger();
  const { ref: aboutUsRef, isTriggered: isAboutUsVisible } = useScrollTrigger();

  const handleClickService = useCallback(() => router.push(SERVICE_PATH), [router]);

  const handleClickAboutUs = useCallback(() => router.push(ABOUT_US_PATH), [router]);
  
  return (
    <Main>
      <BackgroundImage
        src={backgroundSp}
        alt='トップページの背景'
        objectPosition='bottom'
        priority
        display={{ xs: 'inline-block', sm: 'none' }}
      />
      <HeroHeader />
      <Fade in={isServiceVisible} ref={serviceRef} timeout={1000}>
        <div>
          <ServiceWrapper>
            <ViewMoreContainer
              title='SERVICE'
              subTitle='サービス'
              description='企業様のお困りごとを解決に導く経営コンサルティングと人手不足を解決に導くエンジニアソリューションを通して一人でも多くの感動を届けます。'
              image={{ src: meeting, alt: 'meeting', width: 640, height: 427 }}
              onClick={handleClickService}
            />
          </ServiceWrapper>
          <ServiceContent />
        </div>
      </Fade>
      <Fade in={isAboutUsVisible} ref={aboutUsRef} timeout={1000}>
        <AboutUsWrapper>
          <BackgroundImage
            src={background}
            alt='aboutUsの背景画像'
            display={{ xs: 'none', sm: 'inline-block' }}
          />
          <Container maxWidth='lg' sx={{ py: 6 }}>
            <ViewMoreContainer
              title='About Us'
              subTitle='わたしたちについて'
              description={'わたしたちの想いや\nビジョンについてご紹介します。'}
              image={{ src: nakatani, alt: 'nakatani', width: 726, height: 545 }}
              direction='row-reverse'
              onClick={handleClickAboutUs}
            />
          </Container>
        </AboutUsWrapper>
      </Fade>
      <CompanyProfile />
      <Contact />
    </Main>
  );
});

Atoms / Molecules / Organismsを用いてレイアウト構成のみを行います。
基本的に複雑なロジックは実装しないようにします。

Pages

責任:ページのHead内の設定とAPIとの接続

index.tsx
import Head from 'next/head';

import { HomeTemplate } from '@/components/templates/homeTemplate';

const Home = () => {
  return (
    <>
      <Head>
        <title>BrunchMade</title>
        <meta property='og:title' content='BrunchMade' />
      </Head>
      <HomeTemplate />
    </>
  );
};

export default Home;

BrunchMadeのホームページは現時点でAPIを使用することはないため実装されていませんが、責任としてAPIの繋ぎ込みはPageが責任を持つことにしています。
APIのレスポンスデータをTemplateにpropsで受け渡す想定です。

出来上がったページがこちらです。

おわりに

最後までお読みいただきありがとうございました!
Atomic Designは方法論であるので、色々なやり方が存在すると思います。
その中で、今回はTypescript・React・Muiと具体的な実装手段に絞り込んでみました。
同じような実装やアプリケーション・ホームページを開発している方々に、少しでもお役に立てたら嬉しいです🙌
今回は、BrunchMadeのホームページを制作で取り入れたAtomic Designについて学んだことを共有させていただきました。
もし、誤りや不明な点がございましたら、ご指摘いただけますと幸いです🙇‍♂️

BrunchMade テックブログ

Discussion