나는 6개의 JavaScript 위젯을 의존성 없이 만들었습니다 — 각각에서 배운 점

발행: (2026년 3월 1일 오후 05:14 GMT+9)
12 분 소요
원문: Dev.to

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;
});

패턴:

  1. 계산된 스타일을 캡처한다.
  2. transition: none을 설정한다.
  3. 명시적인 값을 설정한다(고정).
  4. 남은 지속 시간으로 트랜지션을 다시 추가한다.

Widget: 카테고리별 토글 스위치를 포함한 모달, 동의 내용 저장.

배운 점: 동의를 두 곳에 저장하기

  • 처음에는 동의를 localStorage에만 저장했습니다.
  • 문제점:
    • 서버‑사이드 렌더링(SSR) 프레임워크에서는 서버에서 localStorage를 읽을 수 없습니다.
    • 프라이버시‑중심 브라우저는 localStorage를 적극적으로 삭제합니다.

해결책: 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에 추가하는 것이 간단해 보이지만—사실은 그렇지 않습니다. 다음 세 가지 상황에서 문제가 발생합니다:

  1. Sticky nav – 스티키 헤더의 top 값이 잘못 설정됩니다.
  2. 스크롤 복원 – 뒤로 가기 탐색 시 브라우저가 바가 렌더링되기 의 스크롤 위치를 복원해 점프가 발생합니다.
  3. 리사이즈 이벤트 – 바의 높이가 변할 수 있습니다(예: 모바일에서 텍스트가 줄바꿈될 때). 따라서 패딩도 업데이트되어야 합니다.

해결책: 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는 선택적으로 사용하고, 스티키 네비게이션, 스크롤 복원, 리사이즈를 처리합니다.
ToastKitToastKit.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.
  • PatternCSS 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!

0 조회
Back to Blog

관련 글

더 보기 »

JavaScript: 시작

JavaScript 1995년, 브렌던 아이크라는 프로그래머가 넷스케이프에서 일하고 있었습니다. 그 당시 웹사이트는 대부분 정적이었으며—정보를 표시할 수는 있었지만, ...

3단계 반응형 E-commerce 헤더

!Triple-Tier Responsive E-commerce Header의 커버 이미지 https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2...

‘skill-check’ JS 퀴즈

질문 1: Type coercion 다음 코드는 콘솔에 무엇을 출력합니까? javascript console.log0 == '0'; console.log0 === '0'; 답변: true, then false