import svgFacebook from "@/icons/facebook.svg?raw";
import svgX from "@/icons/x.svg?raw";
import svgInstagram from "@/icons/instagram.svg?raw";
import svgLinkedin from "@/icons/linkedin.svg?raw";
import svgGoogle from "@/icons/google.svg?raw";
import svgYoutube from "@/icons/youtube.svg?raw";
import svgApple from "@/icons/apple.svg?raw";
import svgSnapchat from "@/icons/snapchat.svg?raw";
import svgPinterest from "@/icons/pinterest.svg?raw";
import svgMedium from "@/icons/medium.svg?raw";
import svgGithub from "@/icons/github.svg?raw";
import svgThreads from "@/icons/threads.svg?raw";
import svgWhatsapp from "@/icons/whatsapp.svg?raw";
import svgFigma from "@/icons/figma.svg?raw";
import svgDribbble from "@/icons/dribbble.svg?raw";
import svgReddit from "@/icons/reddit.svg?raw";
import svgDiscord from "@/icons/discord.svg?raw";
import svgTiktok from "@/icons/tiktok.svg?raw";
import svgTumblr from "@/icons/tumblr.svg?raw";
import svgTelegram from "@/icons/telegram.svg?raw";
import svgBluesky from "@/icons/bluesky.svg?raw";
import svgVkontakte from "@/icons/vkontakte.svg?raw";
import svgSpotify from "@/icons/spotify.svg?raw";
import svgTwitch from "@/icons/twitch.svg?raw";
import svgMessenger from "@/icons/facebook-messenger.svg?raw";
const svg = (paths) => `<svg viewBox="0 0 48 48" aria-hidden="true">${paths}</svg>`;
signal: svg(`<path d="M18.2382 0.700643L18.7769 2.88262C16.6538 3.40733 14.6218 4.24863 12.7492 5.37825L11.5968 3.44857C13.6581 2.20031 15.8976 1.27368 18.2382 0.700643ZM29.7618 0.700643L29.2231 2.88262C31.3462 3.40733 33.3782 4.24863 35.2508 5.37825L36.4168 3.44857C34.3501 2.2014 32.1063 1.27489 29.7618 0.700643ZM3.44854 11.5901C2.20144 13.6543 1.27491 15.8959 0.700615 18.2383L2.88259 18.7769C3.4073 16.6539 4.2486 14.6219 5.37822 12.7492L3.44854 11.5901ZM2.24845 24C2.24829 22.909 2.33034 21.8194 2.49392 20.7407L0.271038 20.3998C-0.0903461 22.7841 -0.0903461 25.2092 0.271038 27.5935L2.49392 27.2593C2.3306 26.1806 2.24854 25.0911 2.24845 24ZM36.4032 44.5447L35.2508 42.6218C33.3811 43.7525 31.3512 44.5939 29.2299 45.1174L29.7686 47.2994C32.1063 46.7209 34.3432 45.7921 36.4032 44.5447ZM45.7515 24C45.7515 25.0911 45.6694 26.1806 45.5061 27.2593L47.729 27.5935C48.0903 25.2092 48.0903 22.7841 47.729 20.3998L45.5061 20.7407C45.6697 21.8194 45.7517 22.909 45.7515 24ZM47.2994 29.755L45.1174 29.2163C44.5939 31.3419 43.7526 33.3764 42.6218 35.2508L44.5515 36.41C45.7998 34.3439 46.7263 32.0999 47.2994 29.755ZM27.2593 45.5061C25.0988 45.8334 22.9012 45.8334 20.7407 45.5061L20.4066 47.729C22.7886 48.0904 25.2114 48.0904 27.5934 47.729L27.2593 45.5061ZM41.5103 36.9009C40.2143 38.6584 38.6603 40.2101 36.9009 41.5035L38.2374 43.3173C40.1753 41.8907 41.8897 40.1832 43.3241 38.251L41.5103 36.9009ZM36.9009 6.48969C38.6605 7.78559 40.2144 9.33955 41.5103 11.0991L43.3241 9.74901C41.8948 7.81512 40.1849 6.10527 38.251 4.67593L36.9009 6.48969ZM6.48966 11.0991C7.78556 9.33955 9.33952 7.78559 11.0991 6.48969L9.74899 4.67593C7.81509 6.10527 6.10524 7.81512 4.6759 9.74901L6.48966 11.0991ZM44.5515 11.5901L42.6218 12.7492C43.7524 14.6189 44.5938 16.6488 45.1174 18.7701L47.2994 18.2314C46.725 15.8913 45.7985 13.6519 44.5515 11.5901ZM20.7407 2.49395C22.9012 2.16662 25.0988 2.16662 27.2593 2.49395L27.5934 0.271067C25.2114 -0.0903557 22.7886 -0.0903557 20.4066 0.271067L20.7407 2.49395ZM7.64202 43.9174L2.99851 44.9947L4.08267 40.3512L1.89388 39.8398L0.809713 44.4833C0.741878 44.7712 0.731468 45.0696 0.779078 45.3616C0.826688 45.6535 0.931384 45.9332 1.08718 46.1846C1.24297 46.436 1.4468 46.6542 1.68702 46.8268C1.92723 46.9994 2.19911 47.1229 2.48711 47.1903C2.82391 47.2653 3.1731 47.2653 3.50991 47.1903L8.15342 46.1198L7.64202 43.9174ZM2.35755 37.8351L4.55316 38.3397L5.30321 35.1213C4.20789 33.2839 3.39208 31.2938 2.88259 29.2163L0.700615 29.755C1.19141 31.7436 1.93381 33.6615 2.90986 35.4622L2.35755 37.8351ZM12.8583 42.7036L9.63989 43.4537L10.1513 45.6493L12.5174 45.097C14.3168 46.076 16.235 46.8185 18.2246 47.3062L18.7633 45.1243C16.6922 44.6081 14.7092 43.7878 12.8788 42.69L12.8583 42.7036ZM24 4.49864C13.2265 4.50546 4.50543 13.2402 4.50543 24.0068C4.51136 27.674 5.54866 31.2656 7.49883 34.3712L5.62369 42.3763L13.622 40.5012C22.7385 46.2357 34.7803 43.5014 40.5148 34.3917C46.2493 25.2819 43.5218 13.2402 34.4121 7.49885C31.2934 5.53767 27.6841 4.49764 24 4.49864Z"/>`)
{ name: "Facebook", href: "https://facebook.com", svg: G.facebook },
{ name: "X/Twitter", href: "https://x.com", svg: G.x },
{ name: "Instagram", href: "https://instagram.com", svg: G.instagram },
{ name: "LinkedIn", href: "https://linkedin.com", svg: G.linkedin },
{ name: "Google", href: "https://google.com", svg: G.google },
{ name: "YouTube", href: "https://youtube.com", svg: G.youtube },
{ name: "Apple", href: "https://apple.com", svg: G.apple },
{ name: "Snapchat", href: "https://snapchat.com", svg: G.snapchat },
{ name: "Pinterest", href: "https://pinterest.com", svg: G.pinterest },
{ name: "Medium", href: "https://medium.com", svg: G.medium },
{ name: "GitHub", href: "https://github.com", svg: G.github },
{ name: "Threads", href: "https://threads.net", svg: G.threads },
{ name: "WhatsApp", href: "https://whatsapp.com", svg: G.whatsapp },
{ name: "Figma", href: "https://figma.com", svg: G.figma },
{ name: "Dribbble", href: "https://dribbble.com", svg: G.dribbble },
{ name: "Reddit", href: "https://reddit.com", svg: G.reddit },
{ name: "Discord", href: "https://discord.com", svg: G.discord },
{ name: "TikTok", href: "https://tiktok.com", svg: G.tiktok },
{ name: "Tumblr", href: "https://tumblr.com", svg: G.tumblr },
{ name: "Telegram", href: "https://telegram.org", svg: G.telegram },
{ name: "Bluesky", href: "https://bsky.app", svg: G.bluesky },
{ name: "Signal", href: "https://signal.org", svg: G.signal },
{ name: "VK", href: "https://vk.com", svg: G.vk },
{ name: "Spotify", href: "https://open.spotify.com", svg: G.spotify },
{ name: "Twitch", href: "https://twitch.tv", svg: G.twitch },
{ name: "Messenger", href: "https://messenger.com", svg: G.messenger }
const DEG = Math.PI / 180;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const mod = (i, n) => (i % n + n) % n;
const damp = (cur, target, lambda, dt) => cur + (target - cur) * (1 - Math.exp(-lambda * dt));
const KNOB_R_FRAC = 0.55;
const TICK_R_FRAC = 0.72;
soft: { ease: "power3.out", dur: 0.5 },
snappy: { ease: "back.out(1.7)", dur: 0.55 },
elastic: { ease: "elastic.out(2, 0.6)", dur: 0.8 }
const SND_MIN_RATE = 0.8;
const SND_MAX_RATE = 1.6;
const SND_VEL_CLAMP = 1.5;
const STYLE_ID = "exp-dial-style";
font-family: var(--font-sans);
--dial-accent: var(--ink);
--dial-accent-ink: var(--paper);
-webkit-user-select: none;
transition: color .3s var(--ease-out-quad);
transition: transform .35s var(--ease-out-expo);
letter-spacing: var(--track);
transition: font-weight .2s,letter-spacing .3s var(--ease-out-quad);
.dial__item.is-active .dial__label {
.dial__item.is-active .dial__ic {
--dial-clear: rgb(from var(--surface) r g b / 0);
background: conic-gradient(from 0deg at var(--dial-cx,40%) var(--dial-cy,50%), var(--surface) 0deg 12deg, var(--dial-clear) 38deg 142deg, var(--surface) 168deg 360deg);
transform: translateY(-.75px);
background: var(--dial-accent);
color: var(--dial-accent-ink);
box-shadow: 0 1px 2px rgba(0,0,0,.12),0 10px 28px -12px rgba(0,0,0,.35);
transform: translate(-50%,-50%);
transition: box-shadow .3s var(--ease-out-quad);
.dial.is-dragging .dial__knob {
box-shadow: 0 2px 4px rgba(0,0,0,.16),0 14px 34px -12px rgba(0,0,0,.42);
background: currentColor;
transform-origin: 50% 50%;
.dial__item:focus-visible {
outline: 2px solid var(--ink);
@media (prefers-reduced-motion: reduce) {
.dial__item,.dial__ic,.dial__label {
if (document.getElementById(STYLE_ID)) return;
const el = document.createElement("style");
document.head.appendChild(el);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const P = { ...DEFAULTS };
const dial = document.createElement("div");
dial.setAttribute("role", "listbox");
dial.setAttribute("aria-label", "Social channels dial");
const stage = document.createElement("div");
stage.className = "dial__stage";
const index = document.createElement("div");
index.className = "dial__index";
const ticksLayer = document.createElement("div");
ticksLayer.className = "dial__ticks";
const ticksRotor = document.createElement("div");
ticksRotor.className = "dial__ticks-rotor";
ticksLayer.appendChild(ticksRotor);
const cover = document.createElement("div");
cover.className = "dial__cover";
const knob = document.createElement("button");
knob.className = "dial__knob";
knob.setAttribute("aria-hidden", "true");
const grip = document.createElement("span");
grip.className = "dial__grip";
const els = ITEMS.map((item, i) => {
const a = document.createElement("a");
a.className = "dial__item";
a.setAttribute("role", "option");
a.innerHTML = `<span class="dial__ic">${item.svg}</span><span class="dial__label">${item.name}</span>`;
stage.append(ticksLayer, cover, index, knob);
const pool = reduced ? [] : Array.from({ length: SND_POOL }, () => {
const a = new Audio("https://carterogunsola.com/lab/dial-tick.mp3");
const playClick = (velDeg) => {
if (!pool.length) return;
const norm = Math.min(velDeg / SND_VEL_DIV, SND_VEL_CLAMP);
const rate = clamp(SND_MIN_RATE + norm * (SND_MAX_RATE - SND_MIN_RATE), SND_MIN_RATE, SND_MAX_RATE);
const a = pool.find((s) => s.paused || s.ended) ?? pool[0];
const w = stage.clientWidth;
const h = stage.clientHeight;
R = clamp(Math.min(h * 0.38, 340), 230, 340);
const vertexX = clamp(w * 0.57, R + 40, w - 40);
knobPx = clamp(R * 0.15, 44, 60);
const knobCx = cx + knobR;
knob.style.left = `${knobCx}px`;
knob.style.width = knob.style.height = `${knobPx}px`;
index.style.left = `${cx + R * TICK_R_FRAC - 6}px`;
index.style.width = `28px`;
ticksRotor.style.left = `${cx}px`;
ticksRotor.style.top = `${cy}px`;
dial.style.setProperty("--dial-cx", `${cx}px`);
dial.style.setProperty("--dial-cy", `${cy}px`);
const buildTicks = () => {
const tickR = R * TICK_R_FRAC;
const frag = document.createDocumentFragment();
for (let k = 0; k < count; k++) {
const major = k % per === 0;
const t = document.createElement("span");
t.className = `dial__tick${major ? " dial__tick--major" : ""}`;
t.style.width = `${major ? 18 : 8}px`;
t.style.transform = `rotate(${k * (360 / count)}deg) translateX(${tickR}px)`;
ticksRotor.replaceChildren(frag);
for (let i = 0; i < N; i++) {
let rel = mod(i - cur, N);
if (rel > N / 2) rel -= N;
const x = cx + R * Math.cos(rad);
const y = cy + R * Math.sin(rad);
els[i].style.transform = `translate(${x}px, ${y}px) translateY(-50%) rotate(${a}deg)`;
ticksRotor.style.transform = `rotate(${-cur * SLICE}deg)`;
grip.style.transform = `rotate(${gripDeg}deg)`;
const idx = mod(Math.round(cur), N);
els[activeIdx]?.classList.remove("is-active");
els[activeIdx]?.setAttribute("aria-selected", "false");
els[idx].classList.add("is-active");
els[idx].setAttribute("aria-selected", "true");
if (prev !== -1) playClick(Math.abs(cur - prevCur) * SLICE * 60);
const tick = (_t, dtMs) => {
const dt = Math.min(dtMs, 64) / 1e3 || 0.016;
const next = damp(cur, target, dragging ? P.dampDrag : P.dampIdle, dt);
if (Math.abs(next - cur) > 5e-5) {
} else if (cur !== target) {
const g = reduced ? gripTarget : damp(gripDeg, gripTarget, 12, dt);
if (Math.abs(g - gripDeg) > 0.01) {
const ac = new AbortController();
const settleTo = (to) => {
const { ease, dur } = EASES[P.detent];
const p = this.targets()[0].p;
target = from + (to - from) * p;
onComplete: () => snap = null
const startDrag = (e) => {
if (e.button != null && e.button !== 0) return;
dial.classList.add("is-dragging");
e.currentTarget.setPointerCapture?.(e.pointerId);
knob.addEventListener("pointerdown", startDrag, { signal });
stage.addEventListener("pointerdown", startDrag, { signal });
const dy = e.clientY - pressY;
if (!moved && Math.abs(dy) > 4) moved = true;
target = pressTarget - dy / P.stepPx;
gripTarget = clamp((target - pressTarget) * SLICE * CAP_SCALE, -GRIP_MAX, GRIP_MAX);
dial.classList.remove("is-dragging");
window.addEventListener("pointerup", endDrag, { signal });
window.addEventListener("pointercancel", endDrag, { signal });
target += e.deltaY / (P.stepPx * 3);
gripTarget = clamp(e.deltaY * 0.2, -GRIP_MAX, GRIP_MAX);
if (wheelIdle) clearTimeout(wheelIdle);
wheelIdle = setTimeout(() => settleTo(target), 150);
{ passive: false, signal }
if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
settleTo(Math.round(cur) - 1);
} else if (e.key === "ArrowDown" || e.key === "ArrowRight") {
settleTo(Math.round(cur) + 1);
let d = mod(i - Math.round(target), N);
settleTo(Math.round(target) + d);
el.addEventListener("dragstart", (e) => e.preventDefault(), { signal });
const ro = new ResizeObserver(() => {
gsap.fromTo(dial, { opacity: 0 }, { opacity: 1, duration: 0.6, ease: "power2.out" });
gsap.from(knob, { scale: 0.4, rotate: -160, duration: 1, ease: "expo.out", delay: 0.05 });
const set = (key, value) => {
dial.style.setProperty("--dial-accent", String(value));
} else if (key === "detent") {
P.detent = String(value);
gsap.ticker.remove(tick);
gsap.killTweensOf([knob, dial]);
if (wheelIdle) clearTimeout(wheelIdle);
experiment.mount(document.querySelector(".stage"));