Taga ToggleA playful pill toggle with an expressive face on the thumb — dead (×× eyes, flat mouth) when off, happy (arc eyes, big smile) when on.
'use client'
// npm install framer-motion
import { useState, useCallback, useLayoutEffect, useEffect, useRef } from 'react'
import { motion, AnimatePresence, useMotionValue, useTransform, animate } from 'framer-motion'
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
// ─── TagaToggle ────────────────────────────────────────────────────────────────
// A playful pill toggle with a face on the thumb.
// Off: dead face (×× eyes, flat mouth). On: happy face (arc eyes, smile, blush).
const MAX_TRACK_W = 80
const MIN_TRACK_W = 48
const FACE_COLOR = '#4A3F35' // warm dark — legible on white in both themes
export default function TagaToggle() {
const [isOn, setIsOn] = useState(false)
const [animating, setAnimating] = useState(false)
const [pageIsDark, setPageIsDark] = useState(() => typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false)
const [trackW, setTrackW] = useState(MAX_TRACK_W)
const containerRef = useRef<HTMLDivElement>(null)
const isOnRef = useRef(false)
// ── Derived dimensions ───────────────────────────────────────────────────────
const trackH = Math.round(trackW * 0.58)
const thumb = Math.round(trackW * 0.50)
const pad = Math.max(3, Math.round(trackW * 0.04))
const offX = pad
const onX = trackW - thumb - pad
const thumbX = useMotionValue(offX)
// ── Theme detection + resize observer ────────────────────────────────────────
useIsomorphicLayoutEffect(() => {
const el = containerRef.current
if (!el) return
const check = () => {
const card = el.closest('[data-card-theme]')
setPageIsDark(
card
? card.classList.contains('dark')
: document.documentElement.classList.contains('dark'),
)
}
check()
const mo = new MutationObserver(check)
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
const cardWrapper = el.closest('[data-card-theme]')
if (cardWrapper) mo.observe(cardWrapper, { attributes: true, attributeFilter: ['class'] })
const update = () => {
const s = Math.max(
MIN_TRACK_W,
Math.min(MAX_TRACK_W, Math.round(Math.min(el.offsetWidth, el.offsetHeight) * 0.18)),
)
setTrackW(s)
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => { mo.disconnect(); ro.disconnect() }
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// ── Snap thumb on resize ─────────────────────────────────────────────────────
useEffect(() => {
thumbX.set(isOnRef.current ? onX : offX)
}, [trackW]) // eslint-disable-line react-hooks/exhaustive-deps
// ── Colours ──────────────────────────────────────────────────────────────────
const offTrack = pageIsDark ? '#4A4540' : '#9E9890'
const onTrack = pageIsDark ? '#D4960A' : '#F5C518'
const trackColor = useTransform(thumbX, [offX, onX], [offTrack, onTrack])
// ── Toggle ───────────────────────────────────────────────────────────────────
const handleToggle = useCallback(async () => {
if (animating) return
setAnimating(true)
const target = isOn ? offX : onX
isOnRef.current = !isOn
setIsOn((v) => !v)
await animate(thumbX, target, { type: 'spring', stiffness: 500, damping: 36 })
setAnimating(false)
}, [isOn, animating, thumbX, offX, onX])
// ── Theme tokens ─────────────────────────────────────────────────────────────
const previewBg = pageIsDark ? '#110F0C' : '#EDEAE5'
const trackInset = pageIsDark
? 'inset 0 1px 4px rgba(0,0,0,0.50)'
: 'inset 0 1px 3px rgba(0,0,0,0.14)'
const thumbShadow = pageIsDark
? '0 3px 8px rgba(0,0,0,0.50), 0 1px 3px rgba(0,0,0,0.30)'
: '0 3px 8px rgba(0,0,0,0.18), 0 1px 3px rgba(0,0,0,0.10)'
// ── Face: mouth paths share M+Q structure → Framer Motion interpolates ───────
const mouthPath = isOn
? 'M -0.40,0.15 Q 0,0.50 0.40,0.15' // smile
: 'M -0.40,0.43 Q 0,0.43 0.40,0.43' // straight (Q control on the line = flat)
const faceSize = thumb * 0.78
// ── Eye animation config ─────────────────────────────────────────────────────
const eyeSpring = { duration: 0.16, ease: [0.34, 1.56, 0.64, 1] as [number, number, number, number] }
return (
<div
ref={containerRef}
className="flex min-h-screen w-full items-center justify-center"
style={{ background: previewBg }}
>
<motion.div
initial={{ opacity: 0, scale: 0.88 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, ease: [0.34, 1.56, 0.64, 1] }}
className="select-none"
>
{/* ── Track ────────────────────────────────────────────────────────────── */}
<motion.button
onClick={handleToggle}
style={{
width: trackW,
height: trackH,
borderRadius: trackH / 2,
backgroundColor: trackColor,
boxShadow: trackInset,
position: 'relative',
cursor: 'pointer',
border: 'none',
outline: 'none',
display: 'block',
}}
whileTap={{ scale: 0.96 }}
transition={{ type: 'spring', stiffness: 400, damping: 28 }}
>
{/* ── Thumb ──────────────────────────────────────────────────────────── */}
<motion.div
style={{
position: 'absolute',
top: pad,
x: thumbX,
width: thumb,
height: thumb,
borderRadius: '50%',
background: 'white',
boxShadow: thumbShadow,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* ── Face SVG ─────────────────────────────────────────────────────── */}
<svg
viewBox="-1 -1 2 2"
width={faceSize}
height={faceSize}
>
{/* Left eye */}
<AnimatePresence mode="wait">
{isOn ? (
<motion.path
key="le-happy"
d="M -0.50,-0.28 Q -0.32,-0.06 -0.14,-0.28"
stroke={FACE_COLOR} strokeWidth={0.13} strokeLinecap="round" fill="none"
initial={{ opacity: 0, scale: 0.3 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.3 }}
transition={eyeSpring}
/>
) : (
<motion.g
key="le-dead"
initial={{ opacity: 0, scale: 0.3 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.3 }}
transition={eyeSpring}
>
<line x1="-0.50" y1="-0.33" x2="-0.14" y2="-0.01"
stroke={FACE_COLOR} strokeWidth={0.13} strokeLinecap="round" />
<line x1="-0.14" y1="-0.33" x2="-0.50" y2="-0.01"
stroke={FACE_COLOR} strokeWidth={0.13} strokeLinecap="round" />
</motion.g>
)}
</AnimatePresence>
{/* Right eye */}
<AnimatePresence mode="wait">
{isOn ? (
<motion.path
key="re-happy"
d="M 0.14,-0.28 Q 0.32,-0.06 0.50,-0.28"
stroke={FACE_COLOR} strokeWidth={0.13} strokeLinecap="round" fill="none"
initial={{ opacity: 0, scale: 0.3 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.3 }}
transition={eyeSpring}
/>
) : (
<motion.g
key="re-dead"
initial={{ opacity: 0, scale: 0.3 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.3 }}
transition={eyeSpring}
>
<line x1="0.14" y1="-0.33" x2="0.50" y2="-0.01"
stroke={FACE_COLOR} strokeWidth={0.13} strokeLinecap="round" />
<line x1="0.50" y1="-0.33" x2="0.14" y2="-0.01"
stroke={FACE_COLOR} strokeWidth={0.13} strokeLinecap="round" />
</motion.g>
)}
</AnimatePresence>
{/* Mouth — path `d` interpolates because both use M+Q */}
<motion.path
d={mouthPath}
stroke={FACE_COLOR}
strokeWidth={0.13}
strokeLinecap="round"
fill="none"
transition={{ duration: 0.28, ease: 'easeInOut' }}
/>
</svg>
</motion.div>
</motion.button>
</motion.div>
</div>
)
}
Add to your project
One command adds this component to your project.
Run the following command. New project? Run npx shadcn@latest init first to set up Tailwind and path aliases.
npx shadcn@latest add @aicanvas/taga-toggleFor dark mode, add the dark class to your <html> element:
<html class="dark">Install with AI Canvas MCP
With AI Canvas MCP, your AI knows every component we ship. Ask for “a navigation component from AI Canvas” inside Claude Code, Codex, or Cursor and it can suggest you a few options, then install the one you like. Less typing, lower token cost, modern way to build.
Get MCPAbout Taga Toggle
Taga Toggle is a personality switch built into a pill toggle: the thumb wears a small face that goes from dead-eyed and flat-mouthed (off) to wide-eyed and grinning (on) while the track warms from grey to yellow. The face transitions are Motion-driven so the morph reads as one continuous gesture rather than a swap. It is the right pick when a feature toggle is part of the brand voice rather than a quiet utility: onboarding flows, product tours, marketing demos, and empty-state CTAs.


