나는 6개의 JavaScript 위젯을 의존성 없이 만들었습니다 — 각각에서 배운 점
Source: Dev.to
번역을 진행하려면 해당 글의 전체 텍스트를 제공해 주시겠어요?
코드 블록, URL 및 마크다운 형식은 그대로 유지하면서 본문만 한국어로 번역해 드리겠습니다.
1️⃣ WhatsApp 채팅 버튼
Widget: 미리 채워진 메시지와 함께 WhatsApp 채팅을 여는 플로팅 버튼.
옵션 팝업 카드: 에이전트 이름, 아바타, 온라인 표시기 포함.
배운 점: 펄스‑애니메이션 전쟁
첫 번째 버전에서는 setInterval을 사용해 펄스 링의 CSS 클래스를 토글했습니다.
이로 인해 layout thrashing이 발생했으며, JavaScript가 브라우저 렌더링 파이프라인과 충돌해 저사양 폰에서 애니메이션이 끊겼습니다.
해결책: 모든 것을 순수 CSS @keyframes 애니메이션으로 옮기고, JS는 클래스를 한 번만 추가/제거하도록 합니다.
// Bad – JS fighting the renderer
setInterval(() => {
pulse.classList.toggle('active');
}, 1000);
// Good – CSS handles the animation entirely
// JS only adds the class once at init
pulse.classList.add('pulse-active');
@keyframes fc-pulse {
0% { transform: scale(1); opacity: 0.7; }
100% { transform: scale(1.8); opacity: 0; }
}
.pulse-active {
animation: fc-pulse 2.2s ease-out infinite;
}
WhatsApp URL 트릭
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
encodeURIComponent는 절대 생략할 수 없습니다 – 이를 사용하지 않으면 &, 이모지 등을 포함한 메시지가 URL을 조용히 깨뜨립니다.
2️⃣ “런던의 Sarah가 방금 구매했습니다” 팝‑업
위젯: 구성 가능한 타이밍으로 알림 목록을 순환합니다.
배운 점: 진행 중인 CSS 트랜지션 일시 정지는 함정이다
위젯은 마우스를 올렸을 때 카운트다운 진행 바를 일시 정지하고, 떠났을 때 멈춘 지점부터 다시 시작합니다.
CSS 트랜지션을 중간에 제거해 일시 정지하면, 요소가 즉시 최종값으로 튀어오릅니다.
해결책: 일시 정지 순간의 계산된 너비를 캡처하고 고정한 뒤, 남은 시간으로 트랜지션을 다시 적용합니다.
element.addEventListener('mouseenter', () => {
// Capture current rendered width BEFORE removing transition
const computed = window.getComputedStyle(progressBar).width;
progressBar.style.transition = 'none';
progressBar.style.width = computed; // freeze it here
// Record when we paused
this._pausedAt = Date.now();
});
element.addEventListener('mouseleave', () => {
const elapsed = Date.now() - this._pausedAt;
this._remaining -= elapsed;
// Resume with remaining time
progressBar.style.transition = `width ${this._remaining}ms linear`;
progressBar.style.width = '0%';
this._pausedAt = null;
});
패턴:
- 계산된 스타일을 캡처한다.
transition: none을 설정한다.- 명시적인 값을 설정한다(고정).
- 남은 지속 시간으로 트랜지션을 다시 추가한다.
3️⃣ Consent Modal (Accept All / Reject / Manage Preferences)
Widget: 카테고리별 토글 스위치를 포함한 모달, 동의 내용 저장.
배운 점: 동의를 두 곳에 저장하기
- 처음에는 동의를
localStorage에만 저장했습니다. - 문제점:
- 서버‑사이드 렌더링(SSR) 프레임워크에서는 서버에서
localStorage를 읽을 수 없습니다. - 프라이버시‑중심 브라우저는
localStorage를 적극적으로 삭제합니다.
- 서버‑사이드 렌더링(SSR) 프레임워크에서는 서버에서
해결책: localStorage 와 쿠키 두 곳에 저장합니다. 먼저 localStorage에서 읽고(더 빠름), 없을 경우 쿠키를 사용합니다.
function saveConsent(key, data, days) {
// Cookie (서버 측에서 접근 가능하고 localStorage 삭제에도 살아남음)
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${key}=${encodeURIComponent(JSON.stringify(data))};expires=${expires};path=/;SameSite=Lax`;
// localStorage (클라이언트 측에서 빠른 읽기)
localStorage.setItem(key, JSON.stringify(data));
}
function loadConsent(key) {
// 먼저 localStorage 시도
const ls = localStorage.getItem(key);
if (ls) return JSON.parse(ls);
// 쿠키로 대체
const match = document.cookie.match(
new RegExp('(?:^|; )' + key + '=([^;]*)')
);
if (match) return JSON.parse(decodeURIComponent(match[1]));
return null; // 저장된 동의가 없음
}
Important: 쿠키에 SameSite=Lax를 설정하는 것은 GDPR 준수를 위해 필수이며, 이 옵션이 없으면 일부 브라우저가 크로스‑오리진 상황에서 쿠키를 차단합니다.
4️⃣ 고정 상단/하단 바
Widget: 프로모션, 세일 카운트다운, 배송 안내용 고정 바; 여러 메시지를 순환합니다.
배운 점: 고정 바를 위한 Body 패딩 오프셋
고정 바가 나타날 때 padding-top(또는 padding-bottom)을 body에 추가하는 것이 간단해 보이지만—사실은 그렇지 않습니다. 다음 세 가지 상황에서 문제가 발생합니다:
- Sticky nav – 스티키 헤더의
top값이 잘못 설정됩니다. - 스크롤 복원 – 뒤로 가기 탐색 시 브라우저가 바가 렌더링되기 전의 스크롤 위치를 복원해 점프가 발생합니다.
- 리사이즈 이벤트 – 바의 높이가 변할 수 있습니다(예: 모바일에서 텍스트가 줄바꿈될 때). 따라서 패딩도 업데이트되어야 합니다.
해결책: offsetBody를 옵트인으로 만들고, 모든 레이아웃 상황을 해결하려 하기보다 경계 사례를 문서화합니다.
if (cfg.position === 'top' && cfg.sticky && cfg.offsetBody) {
document.body.style.paddingTop = cfg.height + 'px';
}
// Clean up on dismiss
dismiss() {
if (cfg.position === 'top' && cfg.offsetBody) {
document.body.style.paddingTop = '';
}
}
Countdown Timer Helper
const diff = new Date(targetDate) - Date.now();
const h = Math.floor(diff / 36e5);
const m = Math.floor((diff % 36e5) / 6e4);
const s = Math.floor((diff % 6e4) / 1e3);
const pad = n => String(n).padStart(2, '0');
// → "02:44:17"
5️⃣ ToastKit
위젯: ToastKit.success("Saved!") – 6가지 유형, 6가지 위치, 라이트/다크/자동 테마, 프라미스 API.
배운 점: 프라미스 패턴이 핵심이다
프라미스 헬퍼를 추가하기 전에는 토스트 시스템이 또 다른 토스트 시스템처럼 느껴졌습니다. 추가하고 나니 모든 것이 딱 맞았습니다.
ToastKit.promise = function(promise, messages) {
const t = ToastKit.loading(messages.loading, { duration: 0 });
promise
.then(() => t.update(messages.success, 'success'))
.catch(() => t.update(messages.error, 'error'));
return promise; // passthrough so you can still await it
};
이제 이렇게 사용할 수 있습니다:
ToastKit.promise(
fetch('/api/save'),
{
loading: 'Saving…',
success: 'Saved!',
error: 'Failed to save.'
}
);
토스트가 별도의 보일러플레이트 없이 비동기 상태를 자동으로 반영합니다.
TL;DR
| 위젯 | 핵심 요점 |
|---|---|
| WhatsApp 버튼 | 순수 CSS @keyframes 로 펄스 효과를 구현하고, JS는 클래스를 한 번만 추가합니다. |
| 구매 팝‑업 | 계산된 스타일을 캡처하고 transition:none 으로 고정한 뒤 다시 재개합니다. |
| 동의 모달 | 동의를 localStorage 와 쿠키(SameSite=Lax) 두 곳에 저장합니다. |
| 스티키 바 | offsetBody는 선택적으로 사용하고, 스티키 네비게이션, 스크롤 복원, 리사이즈를 처리합니다. |
| ToastKit | ToastKit.promise 로 프로미스를 감싸 자동 로딩/성공/오류 토스트를 표시합니다. |
이러한 패턴을 사용하면 작고, 의존성 없는 UI 위젯을 다양한 디바이스, 브라우저 및 렌더링 환경에서 원활하게 동작하도록 배포할 수 있습니다. 즐거운 개발 되세요!
ToastKit – Promise 기반 토스트
// Usage
ToastKit.promise(
fetch('/api/save', { method: 'POST' }),
{
loading: 'Saving...',
success: 'Saved!',
error: 'Failed. Try again.',
}
);
핵심 인사이트: 원래의 프로미스를 반환하면 호출자가 여전히 .then()(또는 await)을 체인할 수 있습니다. 토스트는 단지 부수 효과일 뿐, 게이트가 아닙니다.
자동 다크 모드
let theme = opts.theme;
if (theme === 'auto') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
두 줄. 이걸 위해 몇 시간을 보냈을 수도 있는데, 그럴 필요 없었어요.
읽기‑진행 위젯
페이지 전체 또는 특정 요소(예: 기사)의 읽기 진행 상황을 표시하는 얇은 바가 상단/하단에 나타납니다.
전체 페이지 진행 (쉬움)
const docH = document.documentElement.scrollHeight - window.innerHeight;
const percent = (window.scrollY / docH) * 100;
요소 전용 진행 (어려움)
function getElementProgress(selector) {
const el = document.querySelector(selector);
const rect = el.getBoundingClientRect();
// rect.top은 뷰포트에 상대적이므로 절대 위치로 변환
const elTop = rect.top + window.scrollY;
const elHeight = el.offsetHeight;
const winH = window.innerHeight;
// 요소를 얼마나 스크롤했는가?
const scrolled = window.scrollY + winH - elTop;
return Math.min(100, Math.max(0, (scrolled / elHeight) * 100));
}
window.scrollY + winH는 “뷰포트가 얼마나 스크롤되었는지”를 “뷰포트 하단이 얼마나 이동했는지”로 변환합니다 — 사용자가 실제로 본 영역을 측정하는 기준입니다.
스크롤‑투‑탑 버튼 (부드러운 표시/숨기기)
display를 JavaScript로 토글하지 말고, 클래스를 토글하고 CSS가 애니메이션을 담당하게 하세요.
#scroll-btn {
opacity: 0;
transform: translateY(12px) scale(0.9);
pointer-events: none;
transition: opacity 0.28s ease,
transform 0.28s cubic-bezier(0.34,1.2,0.64,1);
}
#scroll-btn.visible {
opacity: 1;
transform: none;
pointer-events: all;
}
JS에서 visible 클래스를 토글하면; CSS가 애니메이션을 처리합니다. 동일한 원리는 pulse‑ring 효과와 같습니다.
Reflections
- Repetition – Building these widgets felt repetitive at first; they’re all small, self‑contained, and share a similar structure.
- Pattern – CSS should animate, JS should manage state.
- Animating in JavaScript makes the browser fight you.
- Moving the animation to CSS and using JS only to add/remove classes or set CSS variables yields smooth results.
- Edge Cases – The difference between a professional‑feeling widget and a half‑baked one lies almost entirely in edge‑case handling:
- Hovering during animation
- Missing elements
- Mobile behavior
That’s where most of the time goes.
사용 가능한 위젯
All 6 widgets are sold on Gumroad:
- 링크:
rajabdev.gumroad.com - 가격: $9 each
- 기술: Vanilla JS, single script tag
- 데모: The best way to see them is to run the demos.
Feel free to ask any implementation‑detail questions in the comments!