이 Codelab에서는 웹에서 Instagram 스토리와 같은 환경을 빌드하는 방법을 알아봅니다. HTML, CSS, JavaScript 순으로 구성요소를 빌드합니다.
이 구성요소를 빌드하는 동안 이루어진 점진적 개선사항에 관해 알아보려면 내 블로그 게시물 스토리 구성요소 빌드를 확인하세요.
설정
- 리믹스하여 수정을 클릭하여 프로젝트를 수정할 수 있도록 합니다.
app/index.html
를 엽니다.
HTML
항상 시맨틱 HTML을 사용하려고 노력합니다.
각 친구에게는 여러 스토리가 있을 수 있으므로 각 친구에 <section>
요소를 사용하고 각 스토리에 <article>
요소를 사용하는 것이 의미가 있다고 생각했습니다.
하지만 처음부터 시작해 보겠습니다. 먼저 스토리 구성요소의 컨테이너가 필요합니다.
<body>
에 <div>
요소를 추가합니다.
<div class="stories">
</div>
친구를 나타내는 <section>
요소를 추가합니다.
<div class="stories">
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
</div>
스토리를 나타내는 <article>
요소를 추가합니다.
<div class="stories">
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
<article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
</section>
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
</section>
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
</section>
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
<article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
<article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
</section>
</div>
- 스토리 프로토타입을 만드는 데 이미지 서비스 (
picsum.com
)를 사용하고 있습니다. - 각
<article>
의style
속성은 자리표시자 로드 기술의 일부이며 이에 관해서는 다음 섹션에서 자세히 알아봅니다.
CSS
콘텐츠에 스타일을 적용할 준비가 되었습니다. 이 뼈대를 사용자가 상호작용하고 싶어 하는 것으로 바꿔 보겠습니다. 오늘은 모바일 우선으로 작업할 거야.
.stories
<div class="stories">
컨테이너에는 가로 스크롤 컨테이너가 필요합니다.
다음과 같은 방법으로 이를 달성할 수 있습니다.
- 컨테이너를 그리드로 만들기
- 각 하위 요소를 행 트랙에 채우도록 설정
- 각 하위 요소의 너비를 모바일 기기 뷰포트의 너비로 설정
그리드는 마크업의 모든 HTML 요소를 배치할 때까지 이전 요소의 오른쪽에 새 100vw
너비 열을 계속 배치합니다.

app/css/index.css
하단에 다음 CSS를 추가합니다.
.stories {
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
}
이제 표시 영역을 벗어나는 콘텐츠가 있으므로 컨테이너에 콘텐츠 처리 방법을 알려야 합니다. 강조 표시된 코드 줄을 .stories
규칙 집합에 추가합니다.
.stories {
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
overflow-x: auto;
scroll-snap-type: x mandatory;
overscroll-behavior: contain;
touch-action: pan-x;
}
가로 스크롤을 원하므로 overflow-x
를 auto
로 설정합니다. 사용자가 스크롤할 때 구성요소가 다음 스토리에 부드럽게 안착되도록 하려면 scroll-snap-type: x mandatory
를 사용합니다. 이 CSS에 대한 자세한 내용은 블로그 게시물의 CSS 스크롤 스냅 포인트 및 overscroll-behavior 섹션을 참고하세요.
스크롤 스냅에 동의하려면 상위 컨테이너와 하위 요소 모두 동의해야 하므로 지금 처리해 보겠습니다. app/css/index.css
하단에 다음 코드를 추가합니다.
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
}
아직 앱이 작동하지는 않지만 아래 동영상에서는 scroll-snap-type
를 사용 설정 및 중지했을 때 발생하는 상황을 보여줍니다. 이 기능을 사용 설정하면 가로로 스크롤할 때마다 다음 스토리로 스냅됩니다. 사용 중지되면 브라우저에서 기본 스크롤 동작을 사용합니다.
이렇게 하면 친구를 스크롤할 수 있지만 아직 해결해야 할 스토리 문제가 있습니다.
.user
.user
섹션에서 이러한 하위 스토리 요소를 제자리에 배치하는 레이아웃을 만들어 보겠습니다. 이 문제를 해결하기 위해 편리한 스태킹 트릭을 사용하겠습니다.
기본적으로 행과 열에 동일한 그리드 별칭 [story]
이 있는 1x1 그리드를 만들고 각 스토리 그리드 항목이 해당 공간을 차지하려고 시도하여 스택이 생성됩니다.
강조 표시된 코드를 .user
규칙 세트에 추가합니다.
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
display: grid;
grid: [story] 1fr / [story] 1fr;
}
app/css/index.css
하단에 다음 규칙 집합을 추가합니다.
.story {
grid-area: story;
}
이제 절대 위치 지정, 부동 요소 또는 요소를 흐름에서 벗어나게 하는 기타 레이아웃 지시어가 없으므로 여전히 흐름에 있습니다. 게다가 코드가 거의 없습니다. 이 내용은 동영상과 블로그 게시물에서 자세히 설명합니다.
.story
이제 스토리 항목 자체의 스타일을 지정하기만 하면 됩니다.
앞서 각 <article>
요소의 style
속성이 자리표시자 로드 기법의 일부라고 언급했습니다.
<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
배경 이미지를 두 개 이상 지정할 수 있는 CSS의 background-image
속성을 사용합니다. 사용자 사진이 상단에 표시되고 로드가 완료되면 자동으로 표시되도록 순서를 지정할 수 있습니다. 이를 사용 설정하려면 이미지 URL을 맞춤 속성 (--bg
)에 넣고 CSS 내에서 사용하여 로드 자리표시자와 레이어를 만듭니다.
먼저 .story
규칙 집합을 업데이트하여 로드가 완료되면 그라데이션을 배경 이미지로 바꿉니다. 강조 표시된 코드를 .story
규칙 세트에 추가합니다.
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}
background-size
을 cover
로 설정하면 이미지가 뷰포트를 채우므로 뷰포트에 빈 공간이 없습니다. 배경 이미지를 2개 정의하면 로딩 비석이라는 멋진 CSS 웹 트릭을 사용할 수 있습니다.
- 배경 이미지 1 (
var(--bg)
)은 HTML에서 인라인으로 전달한 URL입니다. - 배경 이미지 2 (URL이 로드되는 동안 표시되는 그라데이션)
linear-gradient(to top, lch(98 0 0), lch(90 0 0))
이미지 다운로드가 완료되면 CSS가 그라데이션을 이미지로 자동 대체합니다.
다음으로 CSS를 추가하여 일부 동작을 삭제하고 브라우저가 더 빠르게 이동할 수 있도록 합니다.
강조 표시된 코드를 .story
규칙 세트에 추가합니다.
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
user-select: none;
touch-action: manipulation;
}
user-select: none
를 사용하면 사용자가 실수로 텍스트를 선택하는 것을 방지할 수 있습니다.touch-action: manipulation
는 이러한 상호작용을 터치 이벤트로 처리해야 한다고 브라우저에 지시하므로 브라우저가 URL을 클릭하는지 여부를 결정하지 않아도 됩니다.
마지막으로 스토리 간 전환을 애니메이션으로 처리하기 위해 CSS를 약간 추가합니다. 강조 표시된 코드를 .story
규칙 세트에 추가합니다.
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
user-select: none;
touch-action: manipulation;
transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);
&.seen {
opacity: 0;
pointer-events: none;
}
}
.seen
클래스는 종료가 필요한 스토리에 추가됩니다.
Material Design의 Easing 가이드에서 맞춤 이징 함수 (cubic-bezier(0.4, 0.0, 1,1)
)를 가져왔습니다 (Accerlerated easing 섹션으로 스크롤).
눈썰미가 있다면 pointer-events: none
선언을 알아채고 지금 머리를 긁적이고 있을 것입니다. 지금까지 이 솔루션의 유일한 단점이라고 할 수 있습니다. .seen.story
요소는 보이지 않더라도 상단에 있으며 탭을 수신하므로 이 작업이 필요합니다. pointer-events
을 none
로 설정하면 글래스 스토리가 창으로 바뀌고 더 이상 사용자 상호작용을 훔치지 않습니다. 현재 CSS에서 관리하기가 너무 어렵지 않으며, 나쁘지 않은 절충안입니다. z-index
을 저글링하지 않습니다. 아직도 기분이 좋습니다.
자바스크립트
스토리 구성요소의 상호작용은 사용자에게 매우 간단합니다. 오른쪽을 탭하면 앞으로 이동하고 왼쪽을 탭하면 뒤로 이동합니다. 사용자에게는 간단한 것이 개발자에게는 어려운 작업인 경우가 많습니다. 하지만 Google에서 많은 부분을 처리해 드립니다.
설정
먼저 최대한 많은 정보를 계산하고 저장해 보겠습니다.
다음 코드를 app/js/index.js
에 추가합니다.
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
첫 번째 JavaScript 줄은 기본 HTML 요소 루트에 대한 참조를 가져와 저장합니다. 다음 줄에서는 요소의 중간 위치를 계산하여 탭이 앞으로 이동할지 뒤로 이동할지 결정합니다.
주
다음으로 로직과 관련된 상태가 있는 작은 객체를 만듭니다. 이 경우 현재 스토리에만 관심이 있습니다. HTML 마크업에서 첫 번째 친구와 가장 최근 스토리를 가져와 액세스할 수 있습니다. 강조 표시된 코드를 app/js/index.js
에 추가합니다.
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
리스너
이제 사용자 이벤트를 수신하고 안내할 수 있는 충분한 로직이 있습니다.
쥐
먼저 스토리 컨테이너에서 'click'
이벤트를 리슨해 보겠습니다.
강조 표시된 코드를 app/js/index.js
에 추가합니다.
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
stories.addEventListener('click', e => {
if (e.target.nodeName !== 'ARTICLE')
return
navigateStories(
e.clientX > median
? 'next'
: 'prev')
})
클릭이 발생했지만 <article>
요소에 있지 않으면 중단하고 아무것도 하지 않습니다.
항목인 경우 clientX
를 사용하여 마우스나 손가락의 가로 위치를 가져옵니다. 아직 navigateStories
를 구현하지는 않았지만, navigateStories
가 사용하는 인수는 이동해야 하는 방향을 지정합니다. 해당 사용자 위치가 중앙값보다 크면 next
로 이동해야 하고, 그렇지 않으면 prev
(이전)로 이동해야 합니다.
키보드
이제 키보드 입력을 수신 대기합니다. 아래쪽 화살표를 누르면 next
로 이동합니다. 위쪽 화살표인 경우 prev
로 이동합니다.
강조 표시된 코드를 app/js/index.js
에 추가합니다.
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
stories.addEventListener('click', e => {
if (e.target.nodeName !== 'ARTICLE')
return
navigateStories(
e.clientX > median
? 'next'
: 'prev')
})
document.addEventListener('keydown', ({key}) => {
if (key !== 'ArrowDown' || key !== 'ArrowUp')
navigateStories(
key === 'ArrowDown'
? 'next'
: 'prev')
})
스토리 탐색
스토리의 고유한 비즈니스 로직과 유명해진 UX를 해결할 시간입니다. 이 코드는 복잡하고 까다로워 보이지만 한 줄씩 살펴보면 이해하기 쉬울 것입니다.
처음에 친구로 스크롤할지 아니면 스토리를 표시/숨길지 결정하는 데 도움이 되는 선택기를 저장합니다. 작업은 HTML에서 이루어지므로 친구 (사용자) 또는 스토리 (스토리)의 존재 여부를 쿼리합니다.
이러한 변수는 '스토리 x가 주어졌을 때 '다음'은 이 친구의 다른 스토리로 이동하는 것을 의미하나요 아니면 다른 친구의 스토리로 이동하는 것을 의미하나요?'와 같은 질문에 답하는 데 도움이 됩니다. 제가 한 일은 우리가 구축한 트리 구조를 사용하여 부모와 자녀에게 다가가는 것이었습니다.
app/js/index.js
하단에 다음 코드를 추가합니다.
const navigateStories = direction => {
const story = state.current_story
const lastItemInUserStory = story.parentNode.firstElementChild
const firstItemInUserStory = story.parentNode.lastElementChild
const hasNextUserStory = story.parentElement.nextElementSibling
const hasPrevUserStory = story.parentElement.previousElementSibling
}
비즈니스 로직 목표는 다음과 같습니다. 최대한 자연어에 가깝게 표현했습니다.
- 탭 처리 방법을 결정합니다.
- 다음/이전 스토리가 있는 경우 해당 스토리 표시
- 친구의 마지막/첫 번째 스토리인 경우: 새 친구 표시
- 해당 방향으로 이동할 스토리가 없는 경우: 아무것도 하지 않음
- 새 현재 스토리를
state
에 저장
강조 표시된 코드를 navigateStories
함수에 추가합니다.
const navigateStories = direction => {
const story = state.current_story
const lastItemInUserStory = story.parentNode.firstElementChild
const firstItemInUserStory = story.parentNode.lastElementChild
const hasNextUserStory = story.parentElement.nextElementSibling
const hasPrevUserStory = story.parentElement.previousElementSibling
if (direction === 'next') {
if (lastItemInUserStory === story && !hasNextUserStory)
return
else if (lastItemInUserStory === story && hasNextUserStory) {
state.current_story = story.parentElement.nextElementSibling.lastElementChild
story.parentElement.nextElementSibling.scrollIntoView({
behavior: 'smooth'
})
}
else {
story.classList.add('seen')
state.current_story = story.previousElementSibling
}
}
else if(direction === 'prev') {
if (firstItemInUserStory === story && !hasPrevUserStory)
return
else if (firstItemInUserStory === story && hasPrevUserStory) {
state.current_story = story.parentElement.previousElementSibling.firstElementChild
story.parentElement.previousElementSibling.scrollIntoView({
behavior: 'smooth'
})
}
else {
story.nextElementSibling.classList.remove('seen')
state.current_story = story.nextElementSibling
}
}
}
사용해 보기
- 사이트를 미리 보려면 앱 보기를 누릅니다. 그런 다음 전체 화면
을 누릅니다.
결론
구성요소와 관련해 필요한 사항은 여기까지입니다. 이를 기반으로 구축하고, 데이터로 구동하고, 일반적으로 나만의 것으로 만들어 보세요.