Segmented Control
A sharp-edged, animated segmented control.
Bu componenti anlatmaya başlamadan önce Segmentli Kontrol Bileşeni'nin ne demek olduğunu anlatmak ile başlamak istiyorum.
Segmentli Kontrol Bileşeni Nedir?
Segmentli kontrol, kullanıcıya yatay bir düzlemde iki veya daha fazla seçenek arasından birini seçme imkânı tanıyan bir arayüz öğesidir.Her bir segment (parça) genellikle bir buton gibi davranır ve aktif segmentin arka planı veya çevresi belirgin bir şekilde vurgulanır. Bu tür kontroller, birkaç seçeneğin hızlıca geçiş yapılarak uygulanması gereken durumlarda kullanılır; yani kullanıcıya yakından ilişkili bir seçenek kümesinden tek bir seçenek seçtirir.
"Switcher/Toggle" ile "Segmented Control" Karışıklığı
Bu noktada, "Peki bu 'Switcher/Toggle' ile aynı şey değil mi?" sorusu akla gelebilir. Bu iki terim sıkça birbirinin yerine kullanılsa da aralarında ince bir fark vardır:
- Switcher/Toggle (Anahtar): Genellikle tek bir ayarı "Açık" (On) veya "Kapalı" (Off) yapmak için kullanılır.
- Segmented Control (Segmentli Kontrol): Tanımda da belirttiğim gibi, bir grup seçenek arasından (A, B veya C arasından) yalnızca birini seçmenizi sağlar.
Yani evet, benim tasarladığım [ Light ] ve [ Dark ] tema değiştiricisi, teknik olarak iki segmenti olan bir "Segmented Control" bileşenidir. Yaptığı iş "temayı değiştirmek" olduğu için ona "Theme Switcher" demek de işlevini doğru şekilde anlatır.
Structure
Next + Tailwind + Framer Motion kullanarak yaptığım bu komponent için açık konuşmak gerekirse önemli ve kritik birkaç noktası olduğu söyleyebilirim. Bu yüzden bu componenti 3 kere kodladım desem yanlış olmaz. Adım adım kodu ileriye götürdüm ve optimize etmeye çalıştım. En son adımda yani şu an ki versiyonunda labelların (optionların) genişliğini dinamik olarak hesaplayıp, selector'ın konumunu ve genişliğini hesaplayan bir hale getirdim. Sizler isterseniz switcher/toggle gibi daha az seçenekli ve statik genişlikli basit bir yapı kurabilirsiniz. Kodları parça parça incelemeye başlayalım.
import { useState, useRef, useLayoutEffect, useCallback } from "react";
import { motion } from "framer-motion";
import clsx from "clsx";
Burada ilk göze çarpan nokta useLayoutEffect kullanımı. Paint işlemi tamamlandıktan sonra çalışan useEffect'ten farklı olarak useLayoutEffect kullanmamızın nedeni DOM'dan pozisyon ve genişlik alma işlemini, tarayıcı ekrana render etmeden hemen önce yapmak istememizdir.
Yani "ekran çizilmeden hemen önce, DOM tamamen hazır olduğunda ama kullanıcı henüz görmeden" araya giriyoruz.
interface SegmentedControlProps {
options?: string[];
className?: string;
onValueChange?: (value: string) => void;
}
export default function SegmentedControl({
options = ["Easy", "Medium", "Hard"],
className,
onValueChange,
}: SegmentedControlProps) {
const [selectedOption, setSelectedOption] = useState(options[0]);
const [isAnimating, setIsAnimating] = useState(false);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [selectorStyle, setSelectorStyle] = useState({ left: 0, width: 0 });
Propslarımızı alıyoruz ve state'lerimizi tanımlıyoruz. useRef ile butonların referanslarını bir array içerisinde tutuyoruz.
const handleSelect = useCallback(
(nextOption: string) => {
if (nextOption !== activeOption && !isAnimating) {
setIsAnimating(true);
setActiveOption(nextOption);
}
},
[activeOption, isAnimating]
);
useLayoutEffect(() => {
const selectedIndex = options.indexOf(selectedOption);
const selectedButton = buttonRefs.current[selectedIndex];
if (selectedButton) {
const { offsetLeft, offsetWidth } = selectedButton;
setSelectorStyle((prev) => {
if (prev.left === offsetLeft && prev.width === offsetWidth) return prev;
return { left: offsetLeft, width: offsetWidth };
});
}
}, [selectedOption, options]);
useCallback ile gereksiz render'ların önüne geçiyoruz.
useLayoutEffect, useEffect'ten farklı olarak efektleri tarayıcı boyamadan hemen önce çalıştırır. Bu sayede seçili butonun offsetLeft ve offsetWidth değerlerini kullanıcı ekrana bakmadan önce ölçüp setSelectorStyle ile doğru konum ve genişliği ayarlıyoruz. offsetLeft ve offsetWidth özellikleri için linklerden detaylı bilgi alabilirsiniz.
return (
<div
className={clsx(
"relative inline-flex h-[44px] items-center bg-tertiary p-1 dark:bg-secondary",
"border border-secondary dark:border-secondary",
className
)}
data-name="segmented-control"
>
{/* Animated Selector */}
<motion.div
className="absolute top-1/2 z-10 h-[36px] -translate-y-1/2 overflow-hidden bg-primary dark:bg-[#111]"
initial={false}
animate={selectorStyle}
transition={{
delay: isAnimating ? 0.3 : 0,
duration: isAnimating ? 0.4 : 0,
ease: [0.4, 0, 0.2, 1],
}}
onAnimationComplete={() => setIsAnimating(false)}
>
Komponentimizin tasarım kısmında ki kritik noktalarından birine geldik. Katmanlı bir yapı kullanmaktayız. motion.div ile oluşturduğumuz z-10 classına sahip ve yukarıdan 2. katman olan Animated Selector üst satırlardaki selectorStyle state'ine göre buton arka planını oluşturur ve animasyon işlevini gerçekleştirerek pürüssüz bir kayma görüntüsü işlevi oluşturur. overflow-hidden classına gerek olmasa da defensive programlama pratiği olarak ekli kalmasında bir sakınca yoktur.
onAnimationComplete={() => setIsAnimating(false)} : onAnimationComplete event'i animasyon tamamlandığında triggerlanan bir event'dir ve animasyon tamamlandığında setIsAnimating(false) fonksiyonunu çağırarak animasyon durumunu false yapıyoruz.
<div className="absolute left-0 right-0 top-0 h-px bg-sixth dark:bg-sixth" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-sixth dark:bg-sixth" />
{/* Animated side borders */}
{["left", "right"].map((side) => (
<div key={side} className={`absolute ${side}-0 bottom-0 top-0 w-px`}>
{["top", "bottom"].map((pos) => (
<motion.div
key={`${side}-${pos}`}
className={`absolute ${pos}-0 left-0 h-1/2 w-full bg-sixth dark:bg-sixth ${
pos === "top" ? "origin-top" : "origin-bottom"
}`}
initial={{ scaleY: 1 }}
animate={{ scaleY: isAnimating ? 0 : 1 }}
transition={{
delay: isAnimating ? 0 : 0.2,
duration: 0.3,
ease: [0.4, 0, 0.2, 1],
}}
/>
))}
</div>
))}
</motion.div>
Componentimizin animasyonunu etkileyen en önemli noktaya geldik. Bu componenti kodlamaya başlamadan önce nasıl yapılabilir diye düşünürken herkesin aklına gelen "sahte kenarlık" kullanmak aklıma geldi, yani buton çevresinde 1px'lik (w-p),(h-px) <div>'ler kullanarak borderların yerini tutan kenarlardır. Sahte kenarlar kullanmaadan işimiz gerçekten çok zorlaşırdı.
İlk 2 satırda sahte kenarlıkların statik olan top ve bottom kenarlıklarını oluşturuyoruz. -Animated side borders- kısmı ise componentin havasını veren parça burasıdır. Bu animasyon görüntüsünü almamız için componentimizin sol ve sağ sahte kenarını iki eşit parçaya bölmemiz gerekiyor. İçerideki döngü sayesinde bu bölme işlemini uyguluyoruz. origin-top/origin-bottom classları ile kenarların hangi tarafa doğru kısalmaları gerektiğini söylüyoruz.
{/* Option Buttons */}
{options.map((option, index) => (
<button
key={option}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => handleSelect(option)}
className={clsx(
"relative z-20 h-full cursor-pointer bg-transparent px-4 text-[16px] ",
option === selectedOption ? "text-primary" : "opacity-70 hover:opacity-100"
)}
aria-pressed={option === selectedOption}
>
{option}
</button>
))}
</div>
);
}
Bu parça da ise useRef ile butonun referansını tutarak, dinamik olarak gelen labelları ve tıklanabilir alanlarını oluşturuyoruz.