Paste either snippet into your page — a Custom HTML block on WordPress, Shopify, or Squarespace works too. Both snippets update live as you customize, so what you copy is exactly what you configured.
<!-- Free Click-to-Call Button widget by haynes lab — https://hayneslab.dev/tools/widgets/click-to-call -->
<script data-hl-phone="+18035550142" data-hl-label="Call for a free quote" data-hl-show-hours="true" data-hl-hours-text="Mon–Fri, 8am–6pm ET">
/*!
* haynes lab — Click-to-Call Button widget (free)
* Docs & embed code: https://hayneslab.dev/tools/widgets/click-to-call
*/
(function () {
'use strict';
var PREFIX = 'hlw-click-to-call';
var STYLE_ID = 'hlw-click-to-call-styles';
// Capture the script element now (document.currentScript is null later for async/deferred).
var script = document.currentScript ||
document.querySelector('script[src*="widgets/click-to-call.js"]');
if (!script) {
console.warn('[haynes lab click-to-call] Could not locate the script element; widget not rendered.');
return;
}
function readConfig() {
var cfg = {};
var json = script.getAttribute('data-hl-config');
if (json) {
try {
var parsed = JSON.parse(json);
for (var key in parsed) {
if (!parsed.hasOwnProperty(key)) continue;
// Accept both "label" and "data-hl-label" style keys.
cfg[key.indexOf('data-hl-') === 0 ? key.slice(8) : key] = String(parsed[key]);
}
} catch (err) {
console.warn('[haynes lab click-to-call] Malformed JSON in data-hl-config; ignoring it.', err);
}
}
// Individual data-hl-* attributes override data-hl-config.
var attrs = ['phone', 'label', 'position', 'show-hours', 'hours-text', 'bg'];
for (var i = 0; i < attrs.length; i++) {
var val = script.getAttribute('data-hl-' + attrs[i]);
if (val !== null) cfg[attrs[i]] = val;
}
return cfg;
}
function sanitizeTel(raw) {
var cleaned = String(raw).replace(/[^\d+]/g, '');
// Keep at most a single leading "+".
if (cleaned.charAt(0) === '+') {
cleaned = '+' + cleaned.slice(1).replace(/\+/g, '');
} else {
cleaned = cleaned.replace(/\+/g, '');
}
return cleaned;
}
function friendlyPhone(raw) {
var digits = String(raw).replace(/\D/g, '');
var local = digits;
if (digits.length === 11 && digits.charAt(0) === '1') {
local = digits.slice(1); // North American country code
}
if (local.length === 10) {
return '(' + local.slice(0, 3) + ') ' + local.slice(3, 6) + '-' + local.slice(6);
}
return String(raw); // Unknown format: show as given.
}
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
var css =
'.' + PREFIX + ' { position: fixed; bottom: 16px; z-index: 2147483000; ' +
'font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; ' +
'font-size: 14px; line-height: 1.4; }' +
'.' + PREFIX + ', .' + PREFIX + ' * { box-sizing: border-box; }' +
'.' + PREFIX + '--right { right: 16px; }' +
'.' + PREFIX + '--left { left: 16px; }' +
'.' + PREFIX + '__btn { display: inline-flex; align-items: center; gap: 10px; ' +
'padding: 12px 18px; border-radius: 999px; text-decoration: none; color: #ffffff; ' +
'background-color: var(--hlw-ctc-bg, #22c55e); ' +
'box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); ' +
'transition: transform 0.15s ease, box-shadow 0.15s ease; }' +
'.' + PREFIX + '__btn:hover { transform: translateY(-2px); ' +
'box-shadow: 0 6px 18px rgba(0, 0, 0, 0.3); color: #ffffff; }' +
'.' + PREFIX + '__btn:focus-visible { outline: 2px solid #111827; outline-offset: 2px; }' +
'.' + PREFIX + '__icon { display: inline-flex; flex: 0 0 auto; }' +
'.' + PREFIX + '__icon svg { display: block; }' +
'.' + PREFIX + '__text { display: flex; flex-direction: column; text-align: left; }' +
'.' + PREFIX + '__label { font-weight: 600; font-size: 14px; color: #ffffff; }' +
'.' + PREFIX + '__hours { font-size: 11px; color: rgba(255, 255, 255, 0.85); }' +
'.' + PREFIX + '__credit { display: flex; align-items: center; justify-content: center; gap: 4px; margin-top: 4px; font-size: 11px; ' +
'color: #9ca3af; text-decoration: none; }' +
'.' + PREFIX + '__credit svg { display: block; }' +
'.' + PREFIX + '__credit:hover { color: #6b7280; text-decoration: underline; }' +
'@media (max-width: 480px) { .' + PREFIX + '__text { display: none; } ' +
'.' + PREFIX + '__btn { padding: 12px; } }';
var style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
function phoneIconSvg() {
var svgNS = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', '20');
svg.setAttribute('height', '20');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('aria-hidden', 'true');
var path = document.createElementNS(svgNS, 'path');
path.setAttribute('d',
'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 ' +
'A19.79 19.79 0 0 1 2.08 4.18 2 2 0 0 1 4.06 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81 ' +
'a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 ' +
'2.81.7A2 2 0 0 1 22 16.92z');
path.setAttribute('fill', 'currentColor');
svg.appendChild(path);
return svg;
}
function logoSvg() {
var svgNS = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 32 32');
svg.setAttribute('width', '13');
svg.setAttribute('height', '13');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
function circle(cx, cy, r, attrs) {
var c = document.createElementNS(svgNS, 'circle');
c.setAttribute('cx', cx);
c.setAttribute('cy', cy);
c.setAttribute('r', r);
for (var key in attrs) c.setAttribute(key, attrs[key]);
svg.appendChild(c);
}
circle(16, 16, 16, { fill: '#0f0a0a' });
circle(16, 16, 12, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1, opacity: 0.3 });
circle(16, 16, 9, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1, opacity: 0.5 });
circle(16, 16, 6, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1.2, opacity: 0.9 });
circle(16, 16, 3, { fill: '#06b6d4' });
circle(24.5, 7.5, 2, { fill: '#f97316' });
circle(7, 16, 1.8, { fill: '#f97316' });
circle(20, 20, 1.5, { fill: '#f97316' });
return svg;
}
function build(cfg) {
// The script may run again (live demo re-injection, double embed) —
// remove any previous instance so buttons never stack.
var oldRoots = document.querySelectorAll('.' + PREFIX);
for (var i = 0; i < oldRoots.length; i++) {
oldRoots[i].parentNode.removeChild(oldRoots[i]);
}
var tel = sanitizeTel(cfg.phone);
if (!tel.replace(/\D/g, '').length) {
console.warn('[haynes lab click-to-call] Missing or invalid required attribute data-hl-phone ' +
'(E.164, e.g. "+18035551234"). Widget not rendered.');
return;
}
var position = cfg.position === 'bottom-left' ? 'bottom-left' : 'bottom-right';
var label = cfg.label || 'Call us';
var showHours = cfg['show-hours'] === 'true';
var hoursText = cfg['hours-text'] || 'Mon\u2013Fri, 9am\u20135pm';
var bg = cfg.bg || '#22c55e';
injectStyles();
var root = document.createElement('div');
root.className = PREFIX + ' ' + PREFIX + (position === 'bottom-left' ? '--left' : '--right');
root.style.setProperty('--hlw-ctc-bg', bg);
var btn = document.createElement('a');
btn.className = PREFIX + '__btn';
btn.href = 'tel:' + tel;
btn.setAttribute('aria-label', 'Call ' + friendlyPhone(cfg.phone));
var icon = document.createElement('span');
icon.className = PREFIX + '__icon';
icon.appendChild(phoneIconSvg());
btn.appendChild(icon);
var text = document.createElement('span');
text.className = PREFIX + '__text';
var labelEl = document.createElement('span');
labelEl.className = PREFIX + '__label';
labelEl.textContent = label;
text.appendChild(labelEl);
if (showHours) {
var hoursEl = document.createElement('span');
hoursEl.className = PREFIX + '__hours';
hoursEl.textContent = hoursText;
text.appendChild(hoursEl);
}
btn.appendChild(text);
root.appendChild(btn);
var credit = document.createElement('a');
credit.className = PREFIX + '__credit';
credit.href = 'https://hayneslab.dev/tools/widgets/click-to-call';
credit.target = '_blank';
credit.rel = 'noopener';
credit.appendChild(logoSvg());
credit.appendChild(document.createTextNode('Powered by haynes lab'));
root.appendChild(credit);
// Floating widget: always appends to <body>.
document.body.appendChild(root);
}
function init() {
build(readConfig());
}
if (document.body) {
init();
} else {
document.addEventListener('DOMContentLoaded', init, { once: true });
}
})();
</script>