→ לכל המאגר

B56 · טולטיפ נגיש שמתהפך ליד קצה המסך

התנהגויות Vanilla JS · GSAP טעינה ופידבקטקסט וכותרותניווט ומבנה ממתין

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

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

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

הדמו

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

שימו לב שהמחיר אינו כולל .

טולטיפ הוא רכיב שכולם מזלזלים בו ואז הוא גולש מהמסך. שלוש ההכרעות כאן: הקופסה יושבת ב-`position:fixed` על ה-body ולא בתוך המילה, כדי ש-`overflow:hidden` של סקשן כלשהו לא יחתוך אותה; ההיפוך למטה נבדק לפני המיקום האופקי, כי הוא משנה את הגובה; **המדידה נעשית פעמיים**, כי אחרי שמזיזים את הקופסה שבירת השורות בתוכה יכולה להשתנות והרוחב איתה, ובבדיקה במסך צר זה הספיק כדי לגלוש חמישה פיקסלים החוצה; והחץ ממוקם דרך משתנה CSS ולכן הוא ממשיך להצביע על המילה גם אחרי שהקופסה נצמדה פנימה. הפעלה ב-`focus` ולא רק ב-hover היא מה שהופך את זה לנגיש, ו-`aria-describedby` הוא מה שגורם לקורא מסך להקריא את ההסבר.

קוד להדבקה

הקוד כאן עומד בפני עצמו. כל טוקן עיצוב נושא ברירת מחדל, מחלקות העזר של המאגר מוטמעות בו, ותגי הסקריפט מסודרים לפי הסדר הנכון. פרויקט שמגדיר --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}

/* B56 · טולטיפ נגיש שמתהפך ליד קצה המסך */
.tp-demo{max-width:min(720px,92vw);margin-inline:auto;font-size:17px;line-height:2.1;color:var(--muted, #6a6d85)}
.tp-demo p{margin:0 0 18px}
.tp{border-bottom:1.5px dashed var(--accent, #4a3aff);cursor:help;color:var(--ink, #16182b);font-weight:500;background:none;
  border-inline:0;border-top:0;font-family:inherit;font-size:inherit;padding:0}
.tp:focus-visible{outline:2px solid var(--accent, #4a3aff);outline-offset:3px;border-radius:3px}
.tp-box{position:fixed;z-index:80;max-width:min(300px,80vw);background:var(--ink, #16182b);color:var(--bg, #f7f7fa);font-size:14px;
  line-height:1.6;padding:11px 15px;border-radius:12px;box-shadow:0 14px 40px rgba(20,20,40,.28);
  pointer-events:none;opacity:0;top:0;left:0}
.tp-box::after{content:"";position:absolute;width:11px;height:11px;background:var(--ink, #16182b);transform:rotate(45deg);
  left:var(--ax,50%);margin-left:-5.5px}
.tp-box.top::after{bottom:-5px}
.tp-box.bottom::after{top:-5px}
.tp-row{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:26px}
.tp-row .tp{border:1px solid var(--line, #e4e4ee);border-radius:999px;padding:9px 16px;background:var(--card, #fff)}

HTML

<div class="stage tight"><div class="tp-demo">
  <p>המחיר כולל <button class="tp" data-tip="עד שלושה סבבי תיקונים על העיצוב, לפני המעבר לפיתוח.">שני סבבי תיקונים</button>
  וכן <button class="tp" data-tip="חודש ראשון אחרי העלייה לאוויר: תיקוני באגים ושינויי טקסט קטנים, בלי עלות.">אחריות חודש</button>.
  התשלום מתבצע בשני חלקים, והשני משולם רק אחרי <button class="tp" data-tip="הרגע שבו האתר חי בדומיין שלכם ואפשר להיכנס אליו מגוגל.">העלייה לאוויר</button>.</p>
  <p>שימו לב שהמחיר אינו כולל <button class="tp" data-tip="צילום מקצועי, בנק תמונות בתשלום, או איורים בהזמנה. אפשר לחבר ספק בהמלצתנו.">חומרים ויזואליים</button>.</p>
  <div class="tp-row">
    <button class="tp" data-tip="נבדק בקצה השמאלי: הטולטיפ נצמד פנימה במקום לגלוש החוצה.">בדיקת קצה שמאל</button>
    <button class="tp" data-tip="נבדק בקצה הימני: אותה הצמדה, בכיוון ההפוך.">בדיקת קצה ימין</button>
  </div>
</div></div>

JS

(function(){
  const PAD=10,GAP=10;
  const reduce=matchMedia("(prefers-reduced-motion: reduce)").matches;
  const box=document.createElement("div");
  box.className="tp-box";box.setAttribute("role","tooltip");box.id="tp-box";
  document.body.appendChild(box);
  let current=null;
  function place(trigger){
    box.textContent=trigger.dataset.tip;
    box.classList.remove("top","bottom");
    box.style.width="";
    gsap.set(box,{opacity:0,x:0,y:0});
    // נועלים את הרוחב שנמדד. בלי זה הרוחב בפועל יכול לצאת שונה מזה ששימש לחישוב,
    // והקופסה גולשת מהמסך בכמה פיקסלים דווקא במסכים רחבים.
    box.style.width=box.offsetWidth+"px";
    const vw=document.documentElement.clientWidth;   // בלי רוחב הגלילה, אחרת מפספסים כמה פיקסלים
    const t=trigger.getBoundingClientRect(),b=box.getBoundingClientRect();
    // אם אין מקום למעלה, עוברים למטה. זו ההחלטה הראשונה, כי היא משנה את הכל
    const below=t.top-b.height-GAP<PAD;
    const y=below?t.bottom+GAP:t.top-b.height-GAP;
    // ואז מצמידים אופקית פנימה, כדי שהקופסה לא תגלוש מהמסך
    let x=t.left+t.width/2-b.width/2;
    x=Math.min(Math.max(PAD,x),vw-b.width-PAD);
    box.classList.add(below?"bottom":"top");
    gsap.set(box,{x:x,y:y});
    // בדיקה חוזרת בכל זאת, כרשת ביטחון
    let after=box.getBoundingClientRect();
    for(let i=0;i<2;i++){
      const a=box.getBoundingClientRect();
      const dx=a.right>vw-PAD?(vw-PAD)-a.right:(a.left<PAD?PAD-a.left:0);
      if(!dx)break;
      x+=dx;gsap.set(box,{x:x});after=box.getBoundingClientRect();
    }
    // החץ מכוון למילה, אבל נשאר בתוך גבולות הקופסה גם כשהיא נצמדה לקצה
    const ax=Math.min(Math.max(16,t.left+t.width/2-x),after.width-16);
    box.style.setProperty("--ax",ax+"px");
    gsap.to(box,{opacity:1,duration:reduce?0:.2,ease:"power2.out"});
    trigger.setAttribute("aria-describedby","tp-box");
    current=trigger;
  }
  function hide(){
    if(current)current.removeAttribute("aria-describedby");
    current=null;
    gsap.to(box,{opacity:0,duration:reduce?0:.15});
  }
  document.querySelectorAll(".tp").forEach(t=>{
    t.addEventListener("mouseenter",()=>place(t));
    t.addEventListener("mouseleave",hide);
    t.addEventListener("focus",()=>place(t));
    t.addEventListener("blur",hide);
    t.addEventListener("click",e=>{e.preventDefault();current===t?hide():place(t);});  // מגע
  });
  addEventListener("keydown",e=>{if(e.key==="Escape")hide();});
  addEventListener("scroll",()=>{if(current)hide();},{passive:true});
})();