Quick start
Paste this once, before </body>. Use the API key from your space settings.
<script
src="https://widget.pepita.support/widget.js"
data-key="pk_live_xxxxxxxxxxxxxxxxxxxxxxxx"
defer></script>
That is the whole install: a floating button appears bottom-right, and clicking it opens the chat. Everything else below is optional — placement, styling, and control.
Attributes
| Attribute | Purpose |
|---|---|
data-key required | Your space's public API key. Also gates which origins may embed. |
data-parent-chat | CSS selector of an element to mount the chat window into, instead of floating. |
data-parent-trigger | CSS selector of an element to mount the button into, instead of floating bottom-right. |
data-sound | Set to "off" to silence the reply alert. See The alert sound. |
The unread badge
When there are replies the visitor hasn't seen, the widget sets a
data-pending attribute on the button. Style it however you like —
the dot's look is yours; the signal is ours:
/* a red dot in the top-right corner of the button */
.pepita-trigger[data-pending]::after {
content: "";
position: absolute; top: -1px; right: -1px;
width: 14px; height: 14px; border-radius: 50%;
background: #d9544a; border: 2px solid var(--your-bg);
}
[data-pending]), not a value. A future release may carry a count
(data-pending="3"); existence-based CSS keeps working unchanged, and you
can opt into the number with content: attr(data-pending) whenever you want it.
The button is position: relative in the default skin so the absolutely-positioned dot anchors to it. If you bring your own skin, keep that.
The “new messages” pill
While the panel is open, the transcript sticks to the newest message. If the
visitor scrolls up to re-read history and a reply arrives, the widget won't yank
them down — instead it reveals a small pill at the bottom of the transcript by
setting data-visible on .pepita-newmsg. Clicking the pill
jumps to the latest message. As with the badge, the signal is ours; the look is yours:
.pepita-newmsg[data-visible] {
/* your pill — the widget only toggles data-visible */
background: #286983; color: #fff;
border-radius: 999px; padding: 4px 12px;
}
[data-visible]'s presence,
just like the badge. You only need to paint the pill — the widget sets its
position (and the surrounding .pepita-messages-wrap layout) inline, so the
transcript stays scrollable and the pill stays pinned even under a fully custom skin.
The alert sound
When an operator replies and the visitor isn't watching — panel closed, tab hidden,
or the window unfocused — the widget plays a short chime
(widget-alert.mp3, served from the widget's own origin). It never
sounds for a reply the visitor is already looking at. To silence it:
<script
src="https://widget.pepita.support/widget.js"
data-key="pk_live_xxxxxxxxxxxxxxxxxxxxxxxx"
data-sound="off"
defer></script>
data-pending badge still appears — muting removes the noise, not the
unread signal. Note that browsers block autoplay until the visitor has interacted
with your page, so the first alert of a session may be swallowed by the browser
even with sound on; the badge is the signal you can rely on.
Operator replies are markdown
A reply from your support operator is rendered as markdown — **bold**,
*italic*, `code`, links, bullet and numbered lists, fenced
code blocks and # headings. A single newline becomes a line break. The
resulting elements are ordinary children of .pepita-message--admin, so
style them like any other content:
.pepita-message--admin p { /* … */ }
.pepita-message--admin ul,
.pepita-message--admin ol { /* … */ }
.pepita-message--admin a { /* … */ }
.pepita-message--admin code,
.pepita-message--admin pre { /* … */ }
.pepita-message--admin h1,
.pepita-message--admin h2,
.pepita-message--admin h3 { /* keep these near body size — a document-scale
heading swallows a 300px panel */ }
<script>, event handlers and inline style are
stripped), so nothing executable reaches your page. If you bring your own skin, the
barebone stylesheet is worth reading as a starting point for the rules above.
The greeting in an empty chat
Until the first message, the transcript shows the space's greeting (set in its widget
settings) in .pepita-empty — a child of .pepita-messages,
deliberately not a message bubble, because it isn't from a person. It is markdown,
rendered and sanitized exactly like an operator's reply, so it can hold paragraphs,
lists, links and code. It is removed as soon as the chat has a message.
.pepita-empty { /* the greeting box itself */ }
.pepita-empty p,
.pepita-empty ul,
.pepita-empty a { /* same children as an operator reply */ }
Opening hours and the away notice
A space can keep opening hours. Outside them the widget shows a one-line notice
between the transcript and the composer — .pepita-away, carrying
data-visible whenever it has something to say. The composer stays usable
the whole time: a visitor can always leave a message, and the wording tells them so.
.pepita-away {
/* your notice — the widget only toggles data-visible */
flex: 0 0 auto; /* a flex item of a fixed-height column — see below */
border-top: 1px solid #e5dfd3; padding: 6px 10px;
font-size: 12.5px; color: #6e6a86;
}
.pepita-away[data-visible] { animation: fade-in 0.16s ease-out; }
display on it. The widget shows and hides the
strip with an inline style, so a stylesheet display is overridden —
including the display: flex you might reach for to lay the text out. Match
on [data-visible]'s presence, as with the pill and the badge. The
reopening time is worked out on the visitor's own clock, so “back tomorrow at 9am”
reads in their timezone rather than yours, and the text is rewritten in place instead
of appended — it can never say the same thing twice.
flex: 0 0 auto, because
the notice is a flex item of a fixed-height column: without it a two-line notice is
squeezed instead of taking its space from the transcript. And an absolute
font-size if your message rules use one — .pepita-window
sets no font size, so an em here resolves against whatever your page
inherits, and the notice can come out larger than the messages it sits under. (The
barebone skin does use em — there the messages are relative too, so the
two stay in proportion.)
Styling
The widget renders stable, unstyled class names — bring your own CSS, or load the barebone skin:
<link rel="stylesheet" href="https://widget.pepita.support/widget.css">
| Class | Element |
|---|---|
.pepita-root | Wrapper the widget appends to <body> when it places itself. It also gets .pepita--floating — see below. |
.pepita-trigger | The button that opens/closes the chat (carries [data-pending]). |
.pepita-window | The chat panel; the widget toggles .pepita-window--open when it's shown. |
.pepita-header / .pepita-title | Panel header and its title. |
.pepita-minimize | The button at the right of the header. It closes the panel; the trigger reopens it. |
.pepita-messages | The scrolling transcript. |
.pepita-messages-wrap | Positioned wrapper around the transcript; anchors the new-message pill. |
.pepita-newmsg | The “new messages” pill (carries [data-visible]); click jumps to the latest. |
.pepita-message--visitor / --admin | A single message bubble, by sender. An operator's reply contains rendered markdown children (p, ul, code, …). A visitor's bubble is plain text, and the widget sets white-space: pre-wrap on it inline so their line breaks survive your stylesheet. |
.pepita-empty | The greeting shown while the transcript is empty. Markdown children, like an operator's reply; absent entirely when the space configures no greeting. |
.pepita-away | The opening-hours notice between the transcript and the composer (carries [data-visible]). The widget owns its display — see above. |
.pepita-inputrow / .pepita-input / .pepita-send | The composer. .pepita-input is a <textarea> that grows with its content up to 5 rows, then scrolls (Enter sends, Shift+Enter adds a line). |
Floating vs. mounted
With no data-parent-chat, the widget places itself: it appends
.pepita-root to <body> and adds
.pepita--floating to it. That class is where all corner
positioning lives — if you write your own stylesheet and skip it, the button
and panel land in the page flow instead of the corner. The barebone skin does this:
.pepita--floating { position: fixed; right: 20px; bottom: 20px; z-index: 2147483000; }
.pepita--floating .pepita-window { position: fixed; right: 20px; bottom: 84px; }
Point data-parent-chat / data-parent-trigger at your own
elements and the widget appends the panel / button straight into them. Your
containers then own the placement: no .pepita-root is created, no
.pepita--floating is set, and the rules above are inert.
data-parent-chat but
no data-parent-trigger, the panel goes into your container while the
button is appended to the end of <body> with no positioning class
on it — it will sit in the page flow, not in a corner. Either give the button a
container too, or position .pepita-trigger yourself.
Controlling it from your code
The widget exposes a small global once it boots. Use it to wire your own launcher (e.g. a header button) to the same widget:
// the whole surface: open / close / toggle the panel
window.PepitaChat.open();
window.PepitaChat.close();
window.PepitaChat.toggle();
data-pending
badge are the one, consistent way to integrate — that consistency is the point. There
are no event hooks to subscribe to: unread state lives on the DOM (data-pending),
not in JavaScript. Use open()/toggle() only to drive the panel
from your own element (e.g. a "Contact us" link).
Good to know
- Evergreen, and it never reloads your page. No version in the URL — you never re-embed. When a long-open tab is running an old build, the widget swaps its own bundle in place: it tears down its DOM, listeners and socket, then boots the new one. Your page keeps its state — nothing the visitor was doing is thrown away for a widget update.
- One visitor across sub-domains. A conversation started on
example.comcontinues onapp.example.com: the visitor id lives in a first-party cookie scoped to your registrable domain. - Origins are gated. A key only works from the domains registered for that space.