반응형
22.2.26 (토) +34days
스켈레톤(Skeleton) 이란 ?
데이터가 로드되기 전에 콘텐츠의 자리비움(placeholder)를 표시해서 사용자가 기다리는 시간을 좀 덜 지루하게 느끼게끔 하는 UI
스켈레톤 컴포넌트의 장단점
장점 (Pros)
아래의 표는 80명의 참가자를 대상으로 스켈레톤, 스피너, 블랭크 화면을 보여주며 테스트 했을 때 인지되는 로딩 시간을 보여줌. 결과적으로 블랭크 페이지 < 스피터 < 스켈레톤 순서대로 더 빠르다고 느끼게 됨.
단점 (Cons)
아무래도 스켈레톤을 화면마다 표시해야 되기 때문에 상대적으로 시간이나 비용이 많이 든다.
스켈레톤 컴포넌트 사용 예시
페이스북
링크드인
구글 드라이브
유튜브
더 나은 경험을 위한 스켈레톤 규칙
- 스켈레톤은 콘텐츠의 로드를 방해하면 안된다. 당연한 얘기지만 실제 콘텐츠는 데이터를 가용할 수 있는 시점이 되면 즉시 스켈레톤을 대체해야 된다.
- 스켈레톤을 디자인 할 때 애니메이션을 사용하는 것이 좋다. 애니메이션은 wave, pulse 중 wave를 사용하는 것이 로딩 시간을 더 짧게 느끼게끔 한다.
- 느리고 안정적인 애니메이션을 사용하는 것이 로딩 시간을 더 짧게 느끼게끔 한다.
스켈레톤 실습
Preview
프로젝트 생성
$ npx create-react-app skeleton-playground --template typescript
@emotion/styled, @emotion/react 설치
npm i @emotion/styled @emotion/react
Skeleton.tsx
import styled from '@emotion/styled/macro';
import { keyframes, css } from '@emotion/react';
import { useMemo } from 'react';
interface Props {
width?: number;
height?: number;
circle?: boolean;
rounded?: boolean;
count?: number;
unit?: string; // px, % 등의 단위
animation?: boolean; // 애니메이션 유무
color?: string; // 스켈레톤의 배경 컬러
style?: React.CSSProperties; // 스켈레톤의 추가적인 스타일 객체
}
const pulseKeyframe = keyframes`
0% {
opacity: 1
}
50% {
opacity: 0.4
}
100% {
opacity: 1;
}
`;
const pulseAnimation = css`
animation: ${pulseKeyframe} 1.5s ease-in-out infinite;
`;
const Base = styled.span<Props>`
${({ color }) => color && `background-color: ${color}`};
${({ rounded }) => rounded && 'border-radius: 8px'};
${({ circle }) => circle && 'border-radius: 50%'};
${({ width, height }) => (width || height) && 'display: block'};
${({ animation }) => animation && pulseAnimation};
width: ${({ width, unit }) => width && unit && `${width}${unit}`};
height: ${({ height, unit }) => height && unit && `${height}${unit}`};
`;
const Content = styled.span`
opacity: 0;
`;
export default function Skeleton({
animation = true,
width,
height,
circle,
rounded,
count,
unit = 'px',
color = '#F4F4F4',
style,
}: Props) {
// count 6 => '------'
const content = useMemo(
() => [...Array({ length: count })].map(() => '-').join(''),
[count]
);
return (
<Base
animation={animation}
width={width}
height={height}
circle={circle}
rounded={rounded}
count={count}
unit={unit}
color={color}
style={style}
>
<Content>{content}</Content>
</Base>
);
}
App.tsx
import React, { Component, useEffect, useState } from 'react';
import styled from '@emotion/styled/macro';
import Skeleton from './components/Skeleton';
const Base = styled.div`
display: grid;
width: 100%;
grid-template-columns: repeat(5, 1fr);
column-gap: 12px;
row-gap: 24px;
`;
const Container = styled.div`
display: flex;
flex-direction: column;
box-shadow: rgb(0 0 0 / 4%) 0px 4px 16px 0px;
border-radius: 4px;
`;
const ImageWrapper = styled.div`
width: 100%;
`;
const Image = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
`;
const Info = styled.div`
padding: 1rem;
display: flex;
flex-direction: column;
flex: 1 1 0%;
`;
const Title = styled.h4`
margin: 0;
padding: 0;
font-size: 24px;
`;
const Description = styled.p`
margin: 8px 0 0 0;
padding: 0;
font-size: 16px;
`;
const Placeholder: React.FC = () => (
<Container>
<ImageWrapper>
<Skeleton width={320} height={220} />
</ImageWrapper>
<Info>
<Skeleton width={150} height={29} rounded />
<div style={{ height: '8px' }} />
<Skeleton width={200} height={19} rounded />
</Info>
</Container>
);
const Item: React.FC = () => {
return (
<Container>
<ImageWrapper>
<Image src="https://img.webmd.com/dtmcms/live/webmd/consumer_assets/site_images/article_thumbnails/other/cat_relaxing_on_patio_other/1800x1200_cat_relaxing_on_patio_other.jpg" />
</ImageWrapper>
<Info>
<Title>Cat taking a nap</Title>
<Description>zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz</Description>
</Info>
</Container>
);
};
function App() {
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
setTimeout(() => setLoading(false), 2000);
}, []);
return (
<Base>
{loading
? Array.from({ length: 25 }).map((_, idx) => <Placeholder key={idx} />)
: Array.from({ length: 25 }).map((_, idx) => <Item key={idx} />)}
</Base>
);
}
export default App;
본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다
반응형
댓글