→ לכל המאגר

B31 · סליידר המלצות עם גרירה, חצים ונקודות

התנהגויות CSS scroll-snap · JS כרטיסיםתמונות וגלריותניווט ומבנה ממתין

רצועת ציטוטים שנגררת באצבע ובעכבר, נצמדת לכל כרטיס, ומסונכרנת עם חצים ונקודות. בנוי על גלילה נטיבית, ולכן חלק במובייל ולא נשבר.

מתי משתמשים: המלצות לקוחות, לוגואים, מקרי בוחן, מוצרים נבחרים. אחרי אקורדיון זה הרכיב הכי נפוץ שראינו, וכל עסק צריך אותו.

הדמו כאן עיצובי-ניטרלי בכוונה. כשהמהלך נכנס לפרויקט, מיובאת רק ההתנהגות: הצבעים, הרדיוסים, הפונטים והצללים יורשים את העיצוב של אותו פרויקט.

הדמו

★★★★★

הגיעו עם שאלות שלא חשבנו עליהן, ובסוף קיבלנו אתר שמביא פניות כל שבוע. התהליך היה מסודר ובלי הפתעות.

א
אורית לוימנכ"לית, חברת ייעוץ
★★★★★

שלושה ספקים לפניהם לא הצליחו. כאן הבינו את העסק שלי תוך פגישה אחת, וזה הורגש בכל שורה באתר.

ד
דני כהןבעלים, מוסך
★★★★★

הכי אהבתי שלא נעלמו אחרי העלייה לאוויר. כל שאלה נענתה, וגם חודשיים אחרי עדיין מלווים.

מ
מיכל ברקמנהלת שיווק
★★★★★

העלינו את האתר ותוך שבועיים ראינו הבדל בכמות הפניות. ההשקעה החזירה את עצמה מהר.

י
יוסי אדריבעלים, קליניקה
בנוי על גלילה נטיבית עם scroll-snap ולא על טרנספורמים, ולכן במובייל זה חלק לגמרי, עובד עם אינרציית המערכת, ונשאר נגיש למקלדת. בזמן גרירה בעכבר מכבים את ה-snap ומחזירים אותו בשחרור, אחרת הדפדפן נלחם בגרירה. הכיוון נגזר ממדידת מרכזים ולכן זהה בעברית ובאנגלית.

קוד להדבקה

הקוד כאן עומד בפני עצמו. כל טוקן עיצוב נושא ברירת מחדל, מחלקות העזר של המאגר מוטמעות בו, ותגי הסקריפט מסודרים לפי הסדר הנכון. פרויקט שמגדיר --accent או --line משלו יורש אותם אוטומטית. הרכיב הזה מסתמך על dir="rtl".

פתח קובץ עצמאי ↗
הצג את הקוד כאן

CSS

/* מחלקות עזר של המאגר שהרכיב הזה משתמש בהן */
.stage{padding:var(--sec, clamp(120px, 12vw, 260px)) var(--gutter, clamp(24px, 5vw, 96px));position:relative}
.stage.tight{padding-block:clamp(48px,5vw,90px)}
.stage.full{padding-inline:0}
.ph{display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;border-radius:var(--r, 14px)}
.ph-a{background:linear-gradient(160deg,#3b5bdb,#748ffc)}
.ph-c{background:linear-gradient(160deg,#0b7285,#3bc9db)}
.ph-d{background:linear-gradient(160deg,#2b8a3e,#69db7c)}
.ph-e{background:linear-gradient(160deg,#e8590c,#ffa94d)}
.ph-l{background:rgba(16,18,43,.72);color:#fff;padding:.18em .7em;border-radius:999px;line-height:1.3;font-size:max(.78em,12px)}

/* B31 · סליידר המלצות עם גרירה, חצים ונקודות */
.tq{position:relative;max-width:min(1100px,94vw);margin-inline:auto}
.tq-track{display:flex;gap:var(--gap, clamp(16px, 1.6vw, 36px));overflow-x:auto;scroll-snap-type:x mandatory;scrollbar-width:none;padding-block:6px;
  -webkit-overflow-scrolling:touch;cursor:grab}
.tq-track::-webkit-scrollbar{display:none}
.tq-track.drag{cursor:grabbing;scroll-snap-type:none;user-select:none}
.tq-card{flex:0 0 min(420px,82vw);scroll-snap-align:start;background:var(--card, #fff);border:1px solid var(--line, #e4e4ee);border-radius:var(--r, 14px);
  padding:clamp(22px,2.4vw,34px);display:flex;flex-direction:column;gap:16px}
.tq-stars{color:#f0a500;font-size:15px;letter-spacing:2px}
.tq-card p{margin:0;font-size:clamp(15px,1.4vw,18px);line-height:1.65;color:var(--ink, #16182b)}
.tq-who{display:flex;align-items:center;gap:12px;margin-top:auto}
.tq-av{width:40px;height:40px;border-radius:50%;flex:none;font-size:14px}
.tq-who b{display:block;font-size:15px}
.tq-who span{font-size:13px;color:var(--muted, #6a6d85)}
.tq-bar{display:flex;align-items:center;gap:14px;justify-content:center;margin-top:26px}
.tq-nav{width:44px;height:44px;border-radius:50%;border:1px solid var(--line, #e4e4ee);background:var(--card, #fff);cursor:pointer;font-size:17px;color:var(--ink, #16182b);
  display:grid;place-items:center;transition:background .25s,opacity .25s}
.tq-nav:hover{background:#eceaff}
.tq-nav[disabled]{opacity:.35;cursor:default}
.tq-dots{display:flex;gap:8px}
.tq-dots button{width:8px;height:8px;padding:0;border:0;border-radius:50%;background:var(--line, #e4e4ee);cursor:pointer;transition:/* qa-allow: layout, נקודה של 8px שנמתחת ל-24 */width .3s,background .3s}
.tq-dots button.on{width:24px;border-radius:999px;background:var(--accent, #4a3aff)}

HTML

<div class="stage tight"><div class="tq">
  <div class="tq-track">
    <article class="tq-card"><div class="tq-stars">★★★★★</div><p>הגיעו עם שאלות שלא חשבנו עליהן, ובסוף קיבלנו אתר שמביא פניות כל שבוע. התהליך היה מסודר ובלי הפתעות.</p><div class="tq-who"><span class="tq-av ph ph-a"><span class="ph-l">א</span></span><div><b>אורית לוי</b><span>מנכ"לית, חברת ייעוץ</span></div></div></article>
    <article class="tq-card"><div class="tq-stars">★★★★★</div><p>שלושה ספקים לפניהם לא הצליחו. כאן הבינו את העסק שלי תוך פגישה אחת, וזה הורגש בכל שורה באתר.</p><div class="tq-who"><span class="tq-av ph ph-c"><span class="ph-l">ד</span></span><div><b>דני כהן</b><span>בעלים, מוסך</span></div></div></article>
    <article class="tq-card"><div class="tq-stars">★★★★★</div><p>הכי אהבתי שלא נעלמו אחרי העלייה לאוויר. כל שאלה נענתה, וגם חודשיים אחרי עדיין מלווים.</p><div class="tq-who"><span class="tq-av ph ph-d"><span class="ph-l">מ</span></span><div><b>מיכל ברק</b><span>מנהלת שיווק</span></div></div></article>
    <article class="tq-card"><div class="tq-stars">★★★★★</div><p>העלינו את האתר ותוך שבועיים ראינו הבדל בכמות הפניות. ההשקעה החזירה את עצמה מהר.</p><div class="tq-who"><span class="tq-av ph ph-e"><span class="ph-l">י</span></span><div><b>יוסי אדרי</b><span>בעלים, קליניקה</span></div></div></article>
  </div>
  <div class="tq-bar">
    <button class="tq-nav prev" aria-label="הקודם">→</button>
    <div class="tq-dots" role="tablist" aria-label="מעבר בין המלצות"></div>
    <button class="tq-nav next" aria-label="הבא">←</button>
  </div>
</div></div>

JS

(function(){
  const track=document.querySelector(".tq-track"),cards=[...track.children];
  const dots=document.querySelector(".tq-dots"),prev=document.querySelector(".prev"),next=document.querySelector(".next");
  cards.forEach((c,i)=>{
    const b=document.createElement("button");
    b.setAttribute("role","tab");b.setAttribute("aria-label","המלצה "+(i+1));
    b.addEventListener("click",()=>c.scrollIntoView({behavior:"smooth",block:"nearest",inline:"start"}));
    dots.appendChild(b);
  });
  const buttons=[...dots.children];
  const rtl=getComputedStyle(track).direction==="rtl";
  const startEdge=el=>{const r=el.getBoundingClientRect();return rtl?r.right:r.left;};
  function current(){
    // הכרטיס ששפת ההתחלה שלו הכי קרובה לשפת ההתחלה של המסלול.
    // בעברית שפת ההתחלה היא הימנית, ולכן מודדים אותה ולא את המרכז.
    const s=startEdge(track);
    let best=0,d=1e9;
    cards.forEach((c,i)=>{const dist=Math.abs(startEdge(c)-s);if(dist<d){d=dist;best=i;}});
    return best;
  }
  // scrollLeft שלילי ב-RTL, ולכן כל ההשוואות על הערך המוחלט
  const pos=()=>Math.abs(track.scrollLeft);
  const maxScroll=()=>track.scrollWidth-track.clientWidth;
  const atStart=()=>pos()<4, atEnd=()=>maxScroll()-pos()<4;
  function sync(){
    // בסוף המסלול מסמנים את האחרון גם אם הוא לא הגיע לשפת ההתחלה,
    // כי כשמוצגים כמה כרטיסים במקביל אי אפשר להביא אותו לשם
    const i=atEnd()?cards.length-1:current();
    buttons.forEach((b,n)=>b.classList.toggle("on",n===i));
    prev.disabled=atStart();next.disabled=atEnd();
  }
  track.addEventListener("scroll",sync,{passive:true});
  prev.addEventListener("click",()=>cards[Math.max(0,current()-1)].scrollIntoView({behavior:"smooth",block:"nearest",inline:"start"}));
  next.addEventListener("click",()=>cards[Math.min(cards.length-1,current()+1)].scrollIntoView({behavior:"smooth",block:"nearest",inline:"start"}));
  // גרירה בעכבר. במגע הדפדפן כבר עושה את זה לבד ולכן לא נוגעים.
  let down=false,sx=0,sl=0;
  track.addEventListener("pointerdown",e=>{if(e.pointerType!=="mouse")return;down=true;sx=e.clientX;sl=track.scrollLeft;track.classList.add("drag");});
  track.addEventListener("pointermove",e=>{if(!down)return;track.scrollLeft=sl-(e.clientX-sx);});
  const up=()=>{if(!down)return;down=false;track.classList.remove("drag");
    cards[current()].scrollIntoView({behavior:"smooth",block:"nearest",inline:"start"});};
  ["pointerup","pointerleave","pointercancel"].forEach(ev=>track.addEventListener(ev,up));
  sync();
})();