웹사이트 모션
두 가지 효과를 의도적으로 사용합니다. 모션은 사이트가 기계처럼 보이기 시작하는 지점입니다: 모든 카드와 단락에 적용되면 템플릿처럼 읽히며, 아무도 인지하지 못하는 접근성 버그로 콘텐츠를 잃어버리는 가장 쉬운 방법이기도 합니다. 이 스킬은 그 자리를 차지할 가치가 있는 두 가지 효과를 제공하며, 더 중요한 것은 이 효과들을 안전하게 만드는 계약(contract)을 함께 제공합니다.
페이지가 실제로 긴 경우에만 추가하세요. 세 개의 섹션으로 구성된 사이트에서 리빌(reveal)은 잡음일 뿐입니다. 카운트업(count-up)은 방문자가 멈춰서 읽어야 할 숫자(참가자 수, 운영 연수, 출시된 프로젝트 수)가 필요하며, 장식용이 아니어야 합니다.
전제 조건: website-qa가 먼저 통과해야 합니다. 이 스킬은 해당 테스트 스위트에 테스트 파일을 추가하고 a11y 게이트가 실행되는 방식을 변경합니다.
아래의 클래스 이름은 예시일 뿐, 스캐폴드의 것이 아닙니다
.section-title,.section-subtitle,.hero-stat-number및.hero는new-website의 Astro 템플릿에 0번 나타납니다. 이는 실제 사이트에서 추출된 것입니다. 모든 선택자를 자신의 마크업에 지정하기 전까지는 아무것도 작동하지 않습니다 — CSS, 두 개의 스크립트, 그리고templates/motion.spec.ts상단의CONFIG블록까지도 마찬가지입니다. 선택자가 비어 있으면 명시된 이유와 함께 건너뛰므로, 설정되지 않은 복사본은 제품 버그처럼 보이는 실패 대신 "해당 사항 없음"을 보고합니다.
0. 모션이 아닌 한 줄짜리 점검
아무것도 추가하기 전에, 히어로를 측정하세요:
const h = document.querySelector('.hero'); // 히어로의 선택자
({ vh: innerHeight,
heroHeight: h?.getBoundingClientRect().height ?? null,
nextSectionVisible: h?.nextElementSibling
? innerHeight - h.nextElementSibling.getBoundingClientRect().top : null })
min-height: 100vh가 바인딩되고 있다면 — 히어로 자체 콘텐츠가 뷰포트보다 짧은 경우 — 다음 섹션이 정확히 접힌 부분(fold)에서 시작되어 페이지가 스플래시 화면처럼 읽힙니다. 다음 섹션이 가장자리 위로 살짝 보일 때까지 최소 높이를 줄이세요.
다른 사이트의 숫자를 복사하지 마세요. 올바른 값은 히어로 콘텐츠 높이의 함수입니다. 콘텐츠가 이미 뷰포트를 초과하는 경우 해당 화면 크기에서 min-height를 변경해도 아무 효과가 없으며, 실제 해결책은 히어로에서 문단 하나를 잘라내는 것입니다. 측정한 후 결정하세요.
1. 계약
아래의 모든 내용은 하나의 약속을 지키기 위해 존재합니다: 어떤 효과도 콘텐츠를 접근 불가능하게 만들지 않는다. 세 가지 규칙이며, 우선순위 순서대로 나열됩니다.
-
숨김 상태는 HTML에 작성되지 않고 JS에 의해 추가됩니다. JS가 없거나 페이지 중간에 도착한 방문자는 완성된 페이지를 볼 수 있어야 합니다. 스크립트는 숨김 클래스를 직접 추가하며, 현재 접힌 부분 아래에 있는 요소에만 추가합니다.
-
prefers-reduced-motion: reduce는 모든 것이 보이고 움직임이 없도록 끝납니다 — 이 스킬보다 먼저 존재했던 애니메이션도 포함됩니다. Astro 스캐폴드는html { scroll-behavior: smooth }를 재정의 없이 제공하므로, 이는 상속받는 속성이 아니라 실제로 수행해야 하는 단계입니다:/* 재정의하는 규칙 뒤에 위치해야 함 — §6 참조, 이것이 전체 함정입니다 */ @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } /* 이 사이트가 실행하는 추가적인 주변 애니메이션 */ } -
인쇄는 세 번째 상태입니다. 절대 스크롤되지 않으므로
IntersectionObserver가 절대 실행되지 않으며, 리빌된 제목이 빈 상태로 인쇄됩니다.
2. 스크롤 리빌 — 섹션 제목만
@media (prefers-reduced-motion: no-preference) {
/* 숨김 상태에는 전환 없음. `reveal`은 첫 페인트 후에 추가되므로, 여기에 전환을
설정하면 이미 그려진 제목이 나타나기 전에 0.5초 동안 사라집니다 — 로드 후 바로
스크롤하는 사람에게 보입니다.
보이는 상태에 전환을 설정하면 숨김이 순간적으로 이루어지고 리빌만 애니메이션됩니다. */
.reveal { opacity: 0; transform: translateY(12px); }
.reveal.is-visible {
opacity: 1; transform: none;
transition: opacity 0.55s ease-out, transform 0.55s ease-out;
}
/* 위 규칙과 동일한 특이성이며 스타일시트에서 더 나중에 위치하므로,
위의 단축 속성이 지연 시간을 0으로 재설정할 수 없습니다. */
.reveal-delay.is-visible { transition-delay: 0.09s; }
}
/* 인쇄는 절대 스크롤되지 않으므로 observer가 절대 실행되지 않습니다. */
@media print { .reveal { opacity: 1 !important; transform: none !important; } }
(function () {
if (!('IntersectionObserver' in window)) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
function partnerOf(heading) {
var next = heading.nextElementSibling;
return next && next.matches('.section-subtitle') ? next : null;
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
io.unobserve(entry.target);
entry.target.classList.add('is-visible');
var partner = partnerOf(entry.target);
if (partner) partner.classList.add('is-visible');
});
}, { threshold: 0.15 });
document.querySelectorAll('.section-title').forEach(function (heading) {
// 이미 화면에 있는 것은 그대로 둠 — 지금 숨기면 로드 시 깜빡이다가 다시 나타납니다.
if (heading.getBoundingClientRect().top < window.innerHeight) return;
heading.classList.add('reveal');
var partner = partnerOf(heading);
if (partner) partner.classList.add('reveal', 'reveal-delay');
io.observe(heading);
});
})();
제목만 해당됩니다. 부제목은 한 박자 늦게 따라와서 두 개가 하나의 움직임으로 읽힙니다 — 이것이 여기서 "지연(staggered)"이 의미하는 바입니다. 카드, 목록 항목 및 단락에 적용하는 것은 AI 생성 사이트의 특징입니다.
3. 히어로 통계 카운트업
(function () {
var nums = document.querySelectorAll('.hero-stat-number'); // 선택자
if (!nums.length || !('IntersectionObserver' in window)) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
function countUp(el) {
// textContent를 쓰면 하위 마크업이 영구적으로 평탄화됩니다 — 숫자 안에 스타일이 적용된
// <span>+</span>는 애니메이션에서 살아남지 못합니다.
if (el.children.length) return;
var finalText = el.textContent.trim();
// 숫자, 선택적 그룹 구분자(en에서는 ",", de에서는 "."), 선택적
// 후행 기호. 그 외의 모든 것은 작성된 그대로 유지됩니다: 이들은 게시된 주장이며,
// "4.9"가 절대 49까지 애니메이션되어서는 안 됩니다.
var parts = finalText.match(/^(\d{1,3}(?:([.,])\d{3})*|\d+)(\D*)$/);
if (!parts) return;
var target = parseInt(parts[1].replace(/\D/g, ''), 10);
var groupSep = parts[2] || '';
var suffix = parts[3] || '';
// 런타임 로케일이 아닌 작성된 그룹화를 미러링합니다. 아래의 고정된
// 너비는 작성된 문자열에서 측정되므로, 페이지가 "1.200"이라고 말하는 곳에서
// "1,200"을 렌더링하거나 — 또는 페이지가 그룹화하지 않은 숫자를 그룹화하는 것은
// 다른 너비이며, 이는 바로 고정이 방지하려는 행 이동(row-shove)입니다.
function render(v) {
var digits = String(v);
return (groupSep ? digits.replace(/\B(?=(\d{3})+(?!\d))/g, groupSep) : digits) + suffix;
}
// 먼저 상자를 고정합니다. "0+"는 "1,200+"보다 훨씬 좁으며, 통계는 일반적으로
// 레이블보다 숫자에 의해 크기가 결정되므로, 고정되지 않은 카운트는 매 프레임마다
// 전체 행을 옆으로 밀어냅니다.
// 요소에 이미 있는 인라인 min-width를 기억합니다 — 안정화 시 이를 지우면
// 나중에 다른 사람이 만든 스타일 변경이 조용히 취소됩니다.
var priorMinWidth = el.style.minWidth;
el.style.minWidth = el.getBoundingClientRect().width + 'px';
var done = false;
function settle() {
done = true;
el.textContent = finalText;
el.style.minWidth = priorMinWidth;
}
// 카운트 중간에 인쇄가 발생하면 중간 숫자가 캡처됩니다 — 사이트가 주장하지 않는
// 숫자입니다. 계약 규칙 3은 리빌을 다루며, 이것은 카운트업의 절반입니다.
window.addEventListener('beforeprint', settle);
var started = performance.now();
function frame(now) {
if (done) return;
var t = Math.min((now - started) / 1400, 1);
el.textContent = render(Math.round(target * (1 - Math.pow(1 - t, 3))));
if (t < 1) requestAnimationFrame(frame);
else settle();
}
requestAnimationFrame(frame);
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
io.unobserve(entry.target);
countUp(entry.target);
});
}, { threshold: 0.6 });
nums.forEach(function (n) { io.observe(n); });
})();
최종 값은 HTML에 그대로 유지되므로, JS가 없는 방문자와 크롤러는 실제 숫자를 볼 수 있으며 애니메이션은 일시적으로만 이를 대체합니다.
min-width는 블록 박스에서만 작동합니다. 통계 숫자가 display: inline으로 계산되면 고정은 조용히 무시되고 행이 여전히 점프합니다. 계산된 값을 확인하거나 display: inline-block을 설정하세요.
두 스크립트 모두 DOM이 파싱된 후에 실행하세요 — defer, 모듈, 또는 <body> 끝에. 동기 <head> 스크립트에서는 쿼리가 아무것도 반환하지 않으며 두 효과 모두 조용히 죽습니다.
4. a11y 게이트는 변경되어야 합니다, 그렇지 않으면 조용히 약해집니다
이 부분은 놓치기 쉬우며, 이것이 이 스킬이 website-design-system의 코드 조각이 아닌 별도로 존재하는 이유입니다.
리빌은 제목을 opacity: 0으로 남겨두고 axe가 실행되며, axe는 자신이 보이지 않는다고 간주하는 요소를 건너뜁니다. 색상 대비가 조용히 확인되지 않습니다. 스위트는 계속 통과하고 게이트는 전날보다 약해집니다.
a11y.spec.ts에서 축소된 모션을 강제하여 axe가 항상 완성된 페이지를 보도록 하세요. 아래 스니펫은 해당 파일의 for (const path of PAGES) 구조를 가정합니다 — 실제 스위트가 사용하는 루프나 픽스처에 맞게 조정하세요. goto 앞의 emulateMedia 호출과 그 아래의 카나리아(canary)가 핵심 부분입니다:
for (const path of PAGES) {
test(`a11y: ${path}`, async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(path, { waitUntil: 'load' });
// …변경 없음
test.use({ reducedMotion: 'reduce' })는 대체제가 아닐 수 있습니다. Playwright 1.60에서 한 번 측정되었으며, 다른 저장소의 구성을 대상으로 했습니다 — 이 툴킷의 것이 아니며, 현재 lockfile은 1.64.1을 해결합니다. 거기서test.use형식은 불만 없이 수락되었지만 페이지의matchMedia는 여전히no-preference를 보고했습니다. 동일한 실행에서page.emulateMedia와browser.newContext는 모두 작동했습니다. 이는 옵션 문서와 모순되며 메커니즘이 확립되지 않았으므로, Playwright에 대한 규칙이 아닌 하나의 측정값으로 취급하세요. 실제로 작동하는 것이 관찰된 형식이므로 여기서는emulateMedia를 사용합니다.
어떤 형식을 사용하든, 브라우저에 도달했음을 증명하세요. 실패는 조용히 발생하기 때문입니다 — 그리고 테스트당 호출은 게이트를 약화시키는 페이지에서 잊혀진 한 줄과 같습니다:
// 카나리아. 그것 없이는, 아무것도 하지 않는 게이트는 정확히 게이트처럼 보입니다.
expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches),
'축소된 모션이 브라우저에 도달하지 않았습니다 — a11y 게이트가 결정적이지 않습니다')
.toBe(true);
5. motion.spec.ts — 계약을 현실로 만드는 테스트
이 스킬의 templates/motion.spec.ts를 사이트의 tests/에 복사하세요. 의도적으로 new-website Astro 오버레이에 있지 않습니다: 해당 오버레이는 모든 스캐폴드에 복사되며, 이 스펙은 새로운 사이트에는 없는 효과를 주장합니다 — 모든 새 프로젝트의 첫날에 실패할 것입니다.
아래의 각 행은 계약이 깨지는 특정 방식 때문에 존재합니다. 오른쪽 열은 주장의 목적이며, 얼마나 자주 발생했는지에 대한 주장이 아닙니다:
| 주장 | 잡아내는 버그 |
|---|---|
| 전체 스크롤 후에 보이지 않는 제목이 없음 | 이 스킬이 방지하는 전체 실패 모드 |
| 접힌 부분 위에 있는 것은 로드 시 숨겨지지 않음 | 눈에 띄게 깜빡였다가 다시 나타나는 제목 |
리빌 대상이 media: print 아래에서 보임 | 인쇄 재정의가 삭제되거나 잘못된 순서로 배치됨 |
축소된 모션 아래에서 아무것도 숨겨지지 않고, scroll-behavior는 auto이며, 아무것도 애니메이션되지 않고, .reveal 규칙이 강제로 적용되어 CSS 자체가 작동함을 증명 | 선호도를 무시하는 주변 애니메이션; 부드러운 스크롤이 켜져 있음; 그리고 재정의할 규칙 앞에 배치된 재정의 — 동일한 특이성으로 원래 규칙이 승리하며 수정은 조용한 무효가 됨 |
| 통계 숫자가 게시된 값으로 안정화됨 | 누군가 HTML의 수치를 편집함 |
4행의 강제 클래스 단계는 안전장치가 아닙니다. 축소된 모션 아래에서 리빌의 JS는 중단되므로 숨김 클래스가 추가되지 않고 스타일시트가 실행되지 않습니다 — 강제로 적용하지 않으면 해당 행이 CSS 재정의가 깨져도 통과합니다.
"아무것도 애니메이션되지 않음" 테스트는 페이지의 다른 애니메이션도 다루어야 합니다 — 주변 히어로 글로우, scroll-behavior: smooth. 자신의 효과만 주장하면 테스트 이름이 거짓말이 됩니다.
카운트업 주장은 콘텐츠 스냅샷이지, 애니메이션 테스트가 아닙니다. 최종 프레임은 작성된 문자열을 복원하므로 애니메이션이 죽어도 통과합니다. 가지고 있지 않은 적용 범위를 암시하기보다는 주석에 명시하세요; 테스트 측에서 1.4초 rAF 애니메이션을 샘플링하는 것은 타이밍에 의존하며 불안정한 게이트의 가치가 없습니다.
6. 실제 디버깅 시간을 소모하는 주의사항
- 축소된 모션 재정의는 재정의할 규칙 뒤에 와야 합니다. 동일한 특이성에서는 더 나중에 오는 것이 승리합니다. 원래 선언 옆에 두면 더 자연스럽게 읽히지만 아무 효과가 없습니다.
- 마키(marquee) 스타일 스트립은 Playwright로 호버하거나 클릭할 수 없습니다. 요소가 멈출 때까지 기다리며, 반복 요소는 절대 멈추지 않습니다. 대신 좌표로 마우스를 구동하세요(
page.mouse.move). - 반복 애니메이션을 샘플링하면 랩(wrap)에서 불안정해집니다. 단일 이전/이후 쌍은 하나의 창을 루프 주기와 비교합니다 — 90초 루프의 1초 샘플은 다시 시작 지점에 위치하며, 위치가 앞으로 점프하는 경우 약 90번 실행 중 한 번 발생합니다. 여러 간격을 샘플링하고 다수결을 취하세요; 이 중 하나만 랩을 포함할 수 있습니다.
- 화면의 나머지 부분보다 훨씬 긴 제목을 추가하는 경우 스크롤이 예상과 다르게 동작할 수 있습니다. 일반적으로 문제가 되지 않지만, 리빌 관찰자가 '접힌 부분 아래'를 측정하는 방식에 영향을 줄 수 있습니다.