Getting Started
HTML, CSS & JS
The same foundation as one CSS file and one small script. No React, no Tailwind, no build step. Works in plain HTML, Laravel, Django, Rails, Vue, WordPress — anything that renders HTML.
Setup#
Download sabil.css and sabil.js, then link both with the Inter font. Add s-body to <body>. Every class starts with s- and every token with --s-, so nothing clashes with your other CSS. The script has no dependencies and is only needed for the interactive components.
<!doctype html><html lang="en"> <head> <meta charset="utf-8"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400..700&display=swap"> <link rel="stylesheet" href="sabil.css"> <script src="sabil.js" defer></script> </head> <body class="s-body"> <button class="s-btn" data-s-toast="Hello from Sabil">Sign in</button> </body></html>Dark mode#
Add class="dark" or data-theme="dark" to <html>. The previews below follow the theme of this site.
<html lang="en" class="dark">JavaScript API#
Components wire themselves up from data-s-* attributes, including markup added after the page loads. When you need code, use the global Sabil object and listen for its events.
// ToastsSabil.toast("Saved")Sabil.toast({ title: "Upload failed", description: "Max 10 MB.", variant: "destructive" })Sabil.toast({ title: "Archived", action: { label: "Undo", onClick: () => restore() } })// Dialogs and sheetsSabil.openDialog("edit-profile")Sabil.closeDialog("edit-profile")// After inserting tabs or comboboxes with JavaScriptSabil.init(container)// Events (they bubble)menu.addEventListener("s-select", (e) => console.log(e.detail.value))combobox.addEventListener("s-change", (e) => console.log(e.detail.value))tabs.addEventListener("s-change", (e) => console.log(e.detail.tab))Components#
These need only sabil.css. Each preview runs in an isolated frame, so what you see is exactly what a plain HTML page gets.
Button
Primary uses the blue gradient surface. Add a modifier for other variants and sizes.
Input & Field
Wrap a label, control and hint in s-field. Set aria-invalid for the error state.
Textarea & Select
Native elements with the same border, radius and focus ring as the input.
Checkbox, Radio & Switch
Native inputs restyled. Keyboard and form support come for free.
Segmented Control
Tabs without JavaScript, built on radio inputs. Use a shared name per group.
Badge
Small status labels.
Card
Header, content and footer. Any element works inside.
Alert
A callout with an optional icon.
Avatar
Initials or an image. Group them with s-avatar-group.
Accordion
Native <details>. Opens and closes without JavaScript, and stays accessible.
Table
Readable defaults for tabular data.
Breadcrumb & Pagination
Mark the current page with aria-current.
Progress, Skeleton & Spinner
Loading states. The skeleton shimmer and spinner stop when reduced motion is on.
Tooltip & Kbd
A CSS-only tooltip from the data-s-tooltip attribute. It waits 300ms before showing.
Empty State
A placeholder for lists with nothing in them yet.
Interactive#
These also need sabil.js. Keyboard support, focus management and enter/exit motion are built in. Dialogs and sheets use the native <dialog> element.
Tabs
Tabs with panels. Arrow keys, Home and End move between tabs.
Dialog
Built on native <dialog>: focus is trapped, Escape and the backdrop close it, and focus returns to the trigger.
Alert Dialog
The same dialog, used to confirm a destructive action.
Sheet
A dialog that slides in from the edge with the drawer curve. Add s-sheet--left for the other side.
Dropdown Menu
Opens under its trigger and grows from it. Arrow keys move, Escape closes, focus returns to the button.
Popover
Rich content in a floating panel. Click outside or press Escape to close.
Combobox
Type to filter, arrows to move, Enter to pick. Listen for the s-change event to read the value.
Toast
Call Sabil.toast() from any script, or put data-s-toast on a button. Hover pauses the timer.
Full source#
/*! * Sabil UI — HTML, CSS & JS edition * No Tailwind, no build step. Link this file and use the s-* classes. * Interactive components (tabs, dialog, sheet, menu, popover, combobox, * toast) also need sabil.js. * * <link rel="stylesheet" href="sabil.css"> * <body class="s-body"> ... </body> * * Dark mode: add class="dark" or data-theme="dark" on <html>. *//* ------------------------------------------------------------------ * Tokens * ------------------------------------------------------------------ */:root { --s-font: "Inter", "Inter Variable", system-ui, -apple-system, "Segoe UI", sans-serif; --s-font-mono: ui-monospace, "SF Mono", "Geist Mono", Menlo, Consolas, monospace; --s-radius-sm: 4px; --s-radius-md: 6px; --s-radius-lg: 8px; --s-radius-xl: 12px; --s-radius-2xl: 16px; --s-ease-out: cubic-bezier(0.23, 1, 0.32, 1); --s-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); --s-ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); --s-bg: #ffffff; --s-fg: #0a0a0a; --s-card: #ffffff; --s-popover: #ffffff; --s-primary: #056dff; --s-primary-highlight: #3c86ff; --s-primary-fg: #ffffff; --s-secondary: #f1f5f9; --s-secondary-fg: #0f172b; --s-muted: #f1f5f9; --s-muted-fg: #45556c; --s-accent: #0a0a0a0a; --s-destructive: #e7000b; --s-border: #0a0a0a14; --s-input: #0a0a0a1f; --s-ring: #056dff; --s-raised: #ffffff; --s-sunken: #f1f5f9; --s-panel: #f9f8f6; --s-surface-edge: #00000040; --s-shadow: #0000000d;}.dark,[data-theme="dark"] { --s-bg: #0a0a0a; --s-fg: #fafafa; --s-card: #121212; --s-popover: #161616; --s-primary: #2b7bff; --s-primary-highlight: #5c9bff; --s-secondary: #1d1d1f; --s-secondary-fg: #fafafa; --s-muted: #1d1d1f; --s-muted-fg: #a1a1a6; --s-accent: #ffffff0f; --s-destructive: #ff6467; --s-border: #ffffff17; --s-input: #ffffff24; --s-ring: #3c86ff; --s-raised: #1d1d1f; --s-sunken: #1d1d1f; --s-panel: #1a1918; --s-surface-edge: #00000080; --s-shadow: #00000066;}/* ------------------------------------------------------------------ * Base * ------------------------------------------------------------------ */.s-body { margin: 0; background: var(--s-bg); color: var(--s-fg); font-family: var(--s-font); font-size: 14px; line-height: 1.5; letter-spacing: -0.006em; -webkit-font-smoothing: antialiased;}[class^="s-"],[class*=" s-"],[class^="s-"]::before,[class*=" s-"]::before,[class^="s-"]::after,[class*=" s-"]::after { box-sizing: border-box;}.s-focus:focus-visible,.s-btn:focus-visible,.s-pagination__link:focus-visible,.s-accordion summary:focus-visible { outline: 2px solid var(--s-ring); outline-offset: 2px;}/* ------------------------------------------------------------------ * Button * ------------------------------------------------------------------ */.s-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 40px; margin: 0; padding: 0 16px 2px; border: 0; border-radius: var(--s-radius-lg); font: inherit; font-size: 14px; font-weight: 500; line-height: 1; letter-spacing: -0.01em; white-space: nowrap; text-decoration: none; cursor: pointer; user-select: none; color: var(--s-primary-fg); background-color: var(--s-primary); background-image: linear-gradient(180deg, var(--s-primary-highlight) 0%, var(--s-primary) 100%); box-shadow: inset 0 0 1px var(--s-surface-edge), inset 0 1px 0 #ffffff1f, 0 1px 2px var(--s-shadow); transition: transform 150ms var(--s-ease-out), filter 150ms ease, background-color 150ms ease, color 150ms ease;}.s-btn svg { width: 16px; height: 16px; flex-shrink: 0;}.s-btn:active { transform: scale(0.97);}.s-btn:disabled,.s-btn[aria-disabled="true"] { opacity: 0.5; pointer-events: none;}@media (hover: hover) { .s-btn:hover { filter: brightness(1.06); }}.s-btn--outline { color: var(--s-fg); background: var(--s-raised); box-shadow: 0 0 0 1px var(--s-border), 0 1px 2px var(--s-shadow);}.s-btn--secondary { color: var(--s-secondary-fg); background: var(--s-secondary); box-shadow: none;}.s-btn--ghost { color: var(--s-fg); background: transparent; box-shadow: none;}.s-btn--destructive { color: var(--s-destructive); background: color-mix(in oklab, var(--s-destructive) 10%, transparent); box-shadow: none;}.s-btn--link { height: auto; padding: 0; color: var(--s-primary); background: transparent; box-shadow: none; text-underline-offset: 4px;}@media (hover: hover) { .s-btn--outline:hover, .s-btn--ghost:hover { filter: none; background: color-mix(in oklab, var(--s-bg), var(--s-fg) 4%); } .s-btn--secondary:hover { filter: none; background: color-mix(in oklab, var(--s-secondary), var(--s-fg) 5%); } .s-btn--destructive:hover { filter: none; background: color-mix(in oklab, var(--s-destructive) 18%, transparent); } .s-btn--link:hover { filter: none; text-decoration: underline; }}.s-btn--sm { height: 32px; padding: 0 12px; gap: 6px; font-size: 13px; border-radius: var(--s-radius-md);}.s-btn--lg { height: 44px; padding: 0 20px 2px;}.s-btn--icon { width: 40px; padding: 0;}.s-btn--icon.s-btn--sm { width: 32px;}.s-btn--block { width: 100%;}/* ------------------------------------------------------------------ * Form controls * ------------------------------------------------------------------ */.s-input,.s-textarea,.s-select { display: block; width: 100%; height: 40px; margin: 0; padding: 0 12px; border: 1px solid var(--s-input); border-radius: var(--s-radius-lg); background: var(--s-bg); color: var(--s-fg); font: inherit; font-size: 14px; box-shadow: 0 1px 2px var(--s-shadow); outline: none; transition: border-color 150ms ease, box-shadow 150ms ease;}.s-input::placeholder,.s-textarea::placeholder { color: var(--s-muted-fg);}.s-input:focus-visible,.s-textarea:focus-visible,.s-select:focus-visible { border-color: var(--s-ring); box-shadow: 0 0 0 3px color-mix(in oklab, var(--s-ring) 25%, transparent);}.s-input[aria-invalid="true"],.s-textarea[aria-invalid="true"],.s-select[aria-invalid="true"] { border-color: var(--s-destructive);}.s-input:disabled,.s-textarea:disabled,.s-select:disabled { opacity: 0.5; cursor: not-allowed;}.s-textarea { height: auto; min-height: 80px; padding: 8px 12px; resize: vertical;}.s-select { appearance: none; padding-right: 36px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 16px;}.s-label { display: inline-block; font-size: 14px; font-weight: 500; line-height: 1.2;}.s-field { display: grid; gap: 8px;}.s-field__hint { margin: 0; font-size: 13px; color: var(--s-muted-fg);}.s-field__error { margin: 0; font-size: 13px; color: var(--s-destructive);}/* Checkbox, radio, switch: native inputs with custom looks. */.s-choice { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer;}.s-checkbox,.s-radio { appearance: none; flex-shrink: 0; width: 16px; height: 16px; margin: 0; border: 1px solid var(--s-input); background: var(--s-bg); box-shadow: 0 1px 2px var(--s-shadow); cursor: pointer; transition: background-color 150ms ease, border-color 150ms ease;}.s-checkbox { border-radius: var(--s-radius-sm);}.s-radio { border-radius: 50%;}.s-checkbox:checked { border-color: var(--s-primary); background: var(--s-primary) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") center / 12px no-repeat;}.s-radio:checked { border-color: var(--s-primary); background: radial-gradient(circle, var(--s-primary) 0 3.5px, var(--s-bg) 4px);}.s-checkbox:focus-visible,.s-radio:focus-visible,.s-switch:focus-visible { outline: 2px solid var(--s-ring); outline-offset: 2px;}.s-switch { appearance: none; position: relative; flex-shrink: 0; width: 32px; height: 18px; margin: 0; border-radius: 999px; background: color-mix(in oklab, var(--s-muted-fg) 35%, transparent); cursor: pointer; transition: background-color 150ms ease;}.s-switch::before { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #ffffff; box-shadow: 0 1px 2px #0000002e; transition: transform 200ms var(--s-ease-out);}.s-switch:checked { background: var(--s-primary);}.s-switch:checked::before { transform: translateX(14px);}/* ------------------------------------------------------------------ * Segmented control (tabs without JS, radio inputs) * ------------------------------------------------------------------ */.s-tabs { display: flex; gap: 2px; padding: 2px; border-radius: var(--s-radius-lg); background: var(--s-sunken);}.s-tabs__item { flex: 1; position: relative;}.s-tabs__item input { position: absolute; opacity: 0; pointer-events: none;}.s-tabs__item span { display: flex; align-items: center; justify-content: center; height: 32px; padding: 0 12px; border-radius: var(--s-radius-md); font-size: 14px; font-weight: 500; color: var(--s-muted-fg); cursor: pointer; user-select: none; transition: color 150ms ease, background-color 150ms ease, box-shadow 150ms ease;}.s-tabs__item input:checked + span { background: var(--s-bg); color: var(--s-secondary-fg); box-shadow: 0 1px 2px var(--s-shadow);}.dark .s-tabs__item input:checked + span,[data-theme="dark"] .s-tabs__item input:checked + span { background: #ffffff1a; color: var(--s-fg);}.s-tabs__item input:focus-visible + span { outline: 2px solid var(--s-ring); outline-offset: 1px;}/* ------------------------------------------------------------------ * Badge, kbd, avatar, separator * ------------------------------------------------------------------ */.s-badge { display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 8px; border: 1px solid transparent; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; color: var(--s-primary-fg); background: var(--s-primary);}.s-badge--secondary { color: var(--s-secondary-fg); background: var(--s-secondary);}.s-badge--outline { color: var(--s-fg); background: transparent; border-color: var(--s-border);}.s-badge--destructive { color: var(--s-destructive); background: color-mix(in oklab, var(--s-destructive) 12%, transparent);}.s-kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 4px; border-radius: var(--s-radius-sm); background: var(--s-muted); color: var(--s-muted-fg); font: 500 12px/1 var(--s-font); box-shadow: inset 0 -1px 0 var(--s-border);}.s-avatar { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 32px; height: 32px; overflow: hidden; border-radius: 50%; background: var(--s-muted); color: var(--s-fg); font-size: 12px; font-weight: 500;}.s-avatar img { width: 100%; height: 100%; object-fit: cover;}.s-avatar--lg { width: 40px; height: 40px; font-size: 14px;}.s-avatar-group { display: inline-flex;}.s-avatar-group > .s-avatar { box-shadow: 0 0 0 2px var(--s-bg);}.s-avatar-group > .s-avatar + .s-avatar { margin-left: -8px;}.s-separator { height: 1px; margin: 0; border: 0; background: var(--s-border);}.s-separator--vertical { width: 1px; height: auto; align-self: stretch;}/* ------------------------------------------------------------------ * Card, alert, empty state * ------------------------------------------------------------------ */.s-card { display: flex; flex-direction: column; gap: 20px; padding: 24px; border: 1px solid var(--s-border); border-radius: var(--s-radius-xl); background: var(--s-card); color: var(--s-fg); box-shadow: 0 1px 2px var(--s-shadow);}.s-card__header { display: grid; gap: 4px;}.s-card__title { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em;}.s-card__description { margin: 0; font-size: 14px; color: var(--s-muted-fg);}.s-card__footer { display: flex; gap: 8px;}.s-alert { display: grid; grid-template-columns: 16px 1fr; gap: 2px 12px; padding: 12px 16px; border: 1px solid var(--s-border); border-radius: var(--s-radius-xl); background: var(--s-card); font-size: 14px;}.s-alert > svg { grid-row: span 2; width: 16px; height: 16px; margin-top: 2px;}.s-alert__title { margin: 0; font-weight: 500;}.s-alert__description { margin: 0; color: var(--s-muted-fg);}.s-alert--destructive,.s-alert--destructive .s-alert__description { color: var(--s-destructive);}.s-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 24px; border: 1px dashed var(--s-border); border-radius: var(--s-radius-xl); text-align: center;}.s-empty__icon { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; margin-bottom: 4px; border-radius: var(--s-radius-lg); background: var(--s-muted);}.s-empty__icon svg { width: 20px; height: 20px;}.s-empty__title { margin: 0; font-size: 15px; font-weight: 600;}.s-empty__description { margin: 0 0 8px; max-width: 320px; color: var(--s-muted-fg);}/* ------------------------------------------------------------------ * Accordion (native <details>) * ------------------------------------------------------------------ */.s-accordion { border-bottom: 1px solid var(--s-border);}.s-accordion summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 0; font-size: 14px; font-weight: 500; list-style: none; cursor: pointer;}.s-accordion summary::-webkit-details-marker { display: none;}.s-accordion summary::after { content: ""; width: 16px; height: 16px; flex-shrink: 0; background: currentColor; opacity: 0.6; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") center / contain no-repeat; transition: transform 200ms var(--s-ease-out);}.s-accordion[open] summary::after { transform: rotate(180deg);}.s-accordion__content { padding-bottom: 16px; color: var(--s-muted-fg);}/* ------------------------------------------------------------------ * Table, breadcrumb, pagination * ------------------------------------------------------------------ */.s-table { width: 100%; border-collapse: collapse; font-size: 14px;}.s-table th { height: 40px; padding: 0 8px; border-bottom: 1px solid var(--s-border); color: var(--s-muted-fg); font-weight: 500; text-align: left;}.s-table td { padding: 10px 8px; border-bottom: 1px solid var(--s-border);}.s-table tr:last-child td { border-bottom: 0;}@media (hover: hover) { .s-table tbody tr:hover td { background: var(--s-accent); }}.s-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0; padding: 0; list-style: none; font-size: 14px; color: var(--s-muted-fg);}.s-breadcrumb li + li::before { content: "/"; margin-right: 6px; opacity: 0.6;}.s-breadcrumb a { color: inherit; text-decoration: none; transition: color 150ms ease;}.s-breadcrumb a:hover,.s-breadcrumb [aria-current="page"] { color: var(--s-fg);}.s-pagination { display: flex; align-items: center; gap: 4px;}.s-pagination__link { display: inline-flex; align-items: center; justify-content: center; min-width: 36px; height: 36px; padding: 0 10px; border-radius: var(--s-radius-lg); color: var(--s-fg); font-size: 14px; font-weight: 500; text-decoration: none; transition: background-color 150ms ease;}@media (hover: hover) { .s-pagination__link:hover { background: var(--s-accent); }}.s-pagination__link[aria-current="page"] { background: var(--s-raised); box-shadow: 0 0 0 1px var(--s-border), 0 1px 2px var(--s-shadow);}/* ------------------------------------------------------------------ * Feedback: progress, skeleton, spinner, tooltip * ------------------------------------------------------------------ */.s-progress { appearance: none; display: block; width: 100%; height: 8px; border: 0; border-radius: 999px; overflow: hidden; background: var(--s-muted);}.s-progress::-webkit-progress-bar { background: var(--s-muted);}.s-progress::-webkit-progress-value { border-radius: 999px; background: var(--s-primary); transition: width 300ms var(--s-ease-out);}.s-progress::-moz-progress-bar { border-radius: 999px; background: var(--s-primary);}.s-skeleton { display: block; border-radius: var(--s-radius-md); background: linear-gradient( 90deg, var(--s-muted) 0%, color-mix(in oklab, var(--s-muted), var(--s-bg) 70%) 50%, var(--s-muted) 100% ) 0 0 / 200% 100%; animation: s-shimmer 1.4s linear infinite;}@keyframes s-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; }}.s-spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: s-spin 0.6s linear infinite;}@keyframes s-spin { to { transform: rotate(360deg); }}/* Usage: <button data-s-tooltip="Copy">…</button> */[data-s-tooltip] { position: relative;}[data-s-tooltip]::after { content: attr(data-s-tooltip); position: absolute; bottom: calc(100% + 8px); left: 50%; z-index: 50; padding: 6px 10px; border-radius: var(--s-radius-md); background: var(--s-fg); color: var(--s-bg); font: 500 12px/1.2 var(--s-font); white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, 4px) scale(0.97); transform-origin: bottom center; transition: opacity 150ms var(--s-ease-out), transform 150ms var(--s-ease-out);}[data-s-tooltip]:hover::after,[data-s-tooltip]:focus-visible::after { opacity: 1; transform: translate(-50%, 0) scale(1); transition-delay: 300ms;}/* ================================================================== * Interactive components — need sabil.js * Open/close state is data-state="open" | "closed", set by the script. * ================================================================== *//* ------------------------------------------------------------------ * Tabs with panels * ------------------------------------------------------------------ */.s-tablist { display: flex; gap: 2px; padding: 2px; border-radius: var(--s-radius-lg); background: var(--s-sunken);}.s-tab { flex: 1; height: 32px; padding: 0 12px; border: 0; border-radius: var(--s-radius-md); background: transparent; color: var(--s-muted-fg); font: inherit; font-size: 14px; font-weight: 500; cursor: pointer; transition: color 150ms ease, background-color 150ms ease, box-shadow 150ms ease;}.s-tab:hover { color: var(--s-fg);}.s-tab[aria-selected="true"] { background: var(--s-bg); color: var(--s-secondary-fg); box-shadow: 0 1px 2px var(--s-shadow);}.dark .s-tab[aria-selected="true"],[data-theme="dark"] .s-tab[aria-selected="true"] { background: #ffffff1a; color: var(--s-fg);}.s-tab:focus-visible { outline: 2px solid var(--s-ring); outline-offset: 1px;}.s-tabpanel { padding-top: 12px; font-size: 14px;}.s-tabpanel:focus-visible { outline: 2px solid var(--s-ring); outline-offset: 4px; border-radius: var(--s-radius-md);}/* ------------------------------------------------------------------ * Dialog & sheet (native <dialog>) * ------------------------------------------------------------------ */.s-dialog,.s-sheet { padding: 0; border: 0; background: transparent; color: var(--s-fg); overflow: visible;}.s-dialog { width: 420px; max-width: calc(100% - 32px);}.s-dialog::backdrop,.s-sheet::backdrop { background: rgb(0 0 0 / 0.3); opacity: 0; transition: opacity 200ms var(--s-ease-out);}.s-dialog[data-state="open"]::backdrop,.s-sheet[data-state="open"]::backdrop { opacity: 1;}.s-dialog__panel { display: grid; gap: 16px; padding: 24px; border-radius: var(--s-radius-xl); background: var(--s-popover); box-shadow: 0 0 0 1px var(--s-border), 0 24px 48px -12px rgb(0 0 0 / 0.18); opacity: 0; transform: scale(0.96); transition: opacity 150ms var(--s-ease-out), transform 150ms var(--s-ease-out);}.s-dialog[data-state="open"] .s-dialog__panel { opacity: 1; transform: none; transition-duration: 200ms;}.s-dialog__header { display: grid; gap: 6px;}.s-dialog__title { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em;}.s-dialog__description { margin: 0; font-size: 14px; color: var(--s-muted-fg);}.s-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px;}.s-dialog__close { position: absolute; top: 12px; right: 12px;}.s-sheet { width: 380px; max-width: calc(100% - 48px); height: 100%; max-height: 100%; margin: 0 0 0 auto;}.s-sheet__panel { display: flex; flex-direction: column; gap: 16px; height: 100%; padding: 24px; background: var(--s-popover); box-shadow: -1px 0 0 var(--s-border), -24px 0 48px -12px rgb(0 0 0 / 0.18); transform: translateX(100%); transition: transform 250ms var(--s-ease-drawer);}.s-sheet[data-state="open"] .s-sheet__panel { transform: none; transition-duration: 400ms;}.s-sheet--left { margin: 0 auto 0 0;}.s-sheet--left .s-sheet__panel { box-shadow: 1px 0 0 var(--s-border), 24px 0 48px -12px rgb(0 0 0 / 0.18); transform: translateX(-100%);}/* ------------------------------------------------------------------ * Menu & popover (positioned by sabil.js) * ------------------------------------------------------------------ */.s-menu,.s-popover { position: fixed; z-index: 50; margin: 0; border-radius: var(--s-radius-xl); background: var(--s-popover); color: var(--s-fg); box-shadow: 0 0 0 1px var(--s-border), 0 10px 24px -6px rgb(0 0 0 / 0.12); opacity: 0; transform: scale(0.96); transition: opacity 120ms var(--s-ease-out), transform 120ms var(--s-ease-out);}.s-menu[data-state="open"],.s-popover[data-state="open"] { opacity: 1; transform: none; transition-duration: 180ms;}.s-menu[hidden],.s-popover[hidden] { display: none;}.s-menu { min-width: 200px; padding: 4px;}.s-popover { width: 288px; padding: 16px;}.s-menu__item { display: flex; align-items: center; gap: 8px; width: 100%; height: 32px; padding: 0 8px; border: 0; border-radius: var(--s-radius-lg); background: transparent; color: inherit; font: inherit; font-size: 14px; text-align: left; text-decoration: none; cursor: pointer; outline: none;}.s-menu__item svg { width: 16px; height: 16px; color: var(--s-muted-fg);}.s-menu__item:hover,.s-menu__item:focus-visible,.s-menu__item:focus { background: var(--s-accent);}.s-menu__item--destructive,.s-menu__item--destructive svg { color: var(--s-destructive);}.s-menu__label { padding: 6px 8px; font-size: 12px; font-weight: 500; color: var(--s-muted-fg);}.s-menu__separator { height: 1px; margin: 4px -4px; background: var(--s-border);}.s-menu__shortcut { margin-left: auto; font-size: 12px; color: var(--s-muted-fg);}/* ------------------------------------------------------------------ * Combobox * ------------------------------------------------------------------ */.s-combobox { position: relative; width: 100%;}.s-listbox { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 50; max-height: 240px; margin: 0; padding: 4px; overflow-y: auto; list-style: none; border-radius: var(--s-radius-xl); background: var(--s-popover); box-shadow: 0 0 0 1px var(--s-border), 0 10px 24px -6px rgb(0 0 0 / 0.12); transform-origin: top center; opacity: 0; transform: scale(0.97); transition: opacity 120ms var(--s-ease-out), transform 120ms var(--s-ease-out);}.s-listbox[data-state="open"] { opacity: 1; transform: none; transition-duration: 180ms;}.s-listbox[hidden] { display: none;}.s-option { display: flex; align-items: center; justify-content: space-between; height: 32px; padding: 0 8px; border-radius: var(--s-radius-lg); font-size: 14px; cursor: pointer;}.s-option[data-active="true"] { background: var(--s-accent);}.s-option[aria-selected="true"]::after { content: ""; width: 16px; height: 16px; background: var(--s-primary); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") center / contain no-repeat;}.s-option[hidden] { display: none;}.s-listbox__empty { padding: 16px 8px; font-size: 14px; text-align: center; color: var(--s-muted-fg);}/* ------------------------------------------------------------------ * Toast * ------------------------------------------------------------------ */.s-toaster { position: fixed; right: 16px; bottom: 16px; z-index: 100; display: flex; flex-direction: column-reverse; gap: 8px; width: 356px; max-width: calc(100% - 32px); pointer-events: none;}.s-toast { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 2px 12px; padding: 14px 16px; border-radius: var(--s-radius-xl); background: var(--s-popover); color: var(--s-fg); font-size: 14px; box-shadow: 0 0 0 1px var(--s-border), 0 10px 24px -6px rgb(0 0 0 / 0.12); pointer-events: auto; opacity: 0; transform: translateY(100%); transition: opacity 400ms var(--s-ease-out), transform 400ms var(--s-ease-out);}.s-toast[data-state="open"] { opacity: 1; transform: none;}.s-toast[data-state="closed"] { opacity: 0; transform: scale(0.96); transition-duration: 150ms;}.s-toast__title { margin: 0; font-weight: 500;}.s-toast__description { grid-column: 1; margin: 0; color: var(--s-muted-fg);}.s-toast > .s-btn { grid-row: 1 / span 2; grid-column: 2;}.s-toast--destructive .s-toast__title { color: var(--s-destructive);}/* ------------------------------------------------------------------ * Reduced motion * ------------------------------------------------------------------ */@media (prefers-reduced-motion: reduce) { [class^="s-"], [class*=" s-"], [class^="s-"]::before, [class*=" s-"]::before, [class^="s-"]::after, [class*=" s-"]::after { animation-duration: 1ms !important; transition-duration: 1ms !important; }}
/*! * Sabil UI — sabil.js * Vanilla JavaScript for the interactive components in sabil.css. * No dependencies. Load once, anywhere on the page: * * <script src="sabil.js" defer></script> * * Components wire themselves up from data attributes, including markup * added later (all listeners are delegated to document). * * Tabs <div data-s-tabs> role="tablist" / role="tab" / role="tabpanel" * Dialog <button data-s-open="id"> <dialog id="id" class="s-dialog"> * Sheet <button data-s-open="id"> <dialog id="id" class="s-sheet"> * Close any element with data-s-close inside a dialog or sheet * Menu <button data-s-menu="id"> <div id="id" class="s-menu" role="menu" hidden> * Popover <button data-s-popover="id"> <div id="id" class="s-popover" hidden> * Combobox <div class="s-combobox" data-s-combobox> input + role="listbox" * Toast Sabil.toast({ title, description, variant, action }) * or <button data-s-toast="Title" data-s-toast-description="…"> */;(function () { "use strict" var EXIT_MS = 160 var Sabil = (window.Sabil = window.Sabil || {}) function $(sel, root) { return (root || document).querySelector(sel) } function $$(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)) } /* Opens an element: unhide, then set data-state on the next frame so * the CSS transition runs from the closed state. */ function show(el) { clearTimeout(el._kTimer) el.hidden = false el.setAttribute("data-state", "closed") requestAnimationFrame(function () { requestAnimationFrame(function () { el.setAttribute("data-state", "open") }) }) } /* Closes an element: play the exit transition, then hide it. */ function hide(el, done) { el.setAttribute("data-state", "closed") clearTimeout(el._kTimer) el._kTimer = setTimeout(function () { if (el.getAttribute("data-state") === "closed") { el.hidden = true if (done) done() } }, EXIT_MS) } /* ---------------------------------------------------------------- * Tabs * ---------------------------------------------------------------- */ function selectTab(tab, focus) { var list = tab.closest('[role="tablist"]') var root = tab.closest("[data-s-tabs]") || document $$('[role="tab"]', list).forEach(function (t) { var on = t === tab t.setAttribute("aria-selected", on ? "true" : "false") t.tabIndex = on ? 0 : -1 var panel = document.getElementById(t.getAttribute("aria-controls")) if (panel) panel.hidden = !on }) if (focus) tab.focus() root.dispatchEvent(new CustomEvent("s-change", { detail: { tab: tab }, bubbles: true })) } function initTabs(root) { $$('[data-s-tabs] [role="tablist"]', root).forEach(function (list) { var tabs = $$('[role="tab"]', list) var selected = tabs.filter(function (t) { return t.getAttribute("aria-selected") === "true" })[0] || tabs[0] if (selected) selectTab(selected, false) }) } document.addEventListener("click", function (e) { var tab = e.target.closest('[data-s-tabs] [role="tab"]') if (tab) selectTab(tab, false) }) document.addEventListener("keydown", function (e) { var tab = e.target.closest && e.target.closest('[data-s-tabs] [role="tab"]') if (!tab) return var tabs = $$('[role="tab"]', tab.closest('[role="tablist"]')) var i = tabs.indexOf(tab) var next = null if (e.key === "ArrowRight") next = tabs[(i + 1) % tabs.length] if (e.key === "ArrowLeft") next = tabs[(i - 1 + tabs.length) % tabs.length] if (e.key === "Home") next = tabs[0] if (e.key === "End") next = tabs[tabs.length - 1] if (next) { e.preventDefault() selectTab(next, true) } }) /* ---------------------------------------------------------------- * Dialog & sheet (native <dialog>: focus trap, Escape, top layer) * ---------------------------------------------------------------- */ function openDialog(dialog, trigger) { if (!dialog || dialog.open) return dialog._kTrigger = trigger || document.activeElement dialog.showModal() dialog.setAttribute("data-state", "closed") requestAnimationFrame(function () { requestAnimationFrame(function () { dialog.setAttribute("data-state", "open") }) }) } function closeDialog(dialog) { if (!dialog || !dialog.open) return var isSheet = dialog.classList.contains("s-sheet") dialog.setAttribute("data-state", "closed") setTimeout(function () { dialog.close() if (dialog._kTrigger && dialog._kTrigger.focus) dialog._kTrigger.focus() }, isSheet ? 250 : EXIT_MS) } Sabil.openDialog = function (id) { openDialog(document.getElementById(id)) } Sabil.closeDialog = function (id) { closeDialog(document.getElementById(id)) } document.addEventListener("click", function (e) { var opener = e.target.closest("[data-s-open]") if (opener) { openDialog(document.getElementById(opener.getAttribute("data-s-open")), opener) return } var closer = e.target.closest("[data-s-close]") if (closer) { closeDialog(closer.closest("dialog")) return } // A click on the <dialog> element itself is a click on the backdrop, // because the visible panel is a child that fills the content box. if (e.target.matches && e.target.matches("dialog.s-dialog, dialog.s-sheet")) { closeDialog(e.target) } }) // Escape: animate out instead of closing instantly. document.addEventListener( "cancel", function (e) { if (e.target.matches && e.target.matches("dialog.s-dialog, dialog.s-sheet")) { e.preventDefault() closeDialog(e.target) } }, true ) /* ---------------------------------------------------------------- * Menu & popover (shared floating logic) * ---------------------------------------------------------------- */ var openFloating = null // { el, trigger, kind } /* Places el under (or above) the trigger and sets the transform * origin to the trigger side, so it grows out of the button. */ function position(trigger, el) { var r = trigger.getBoundingClientRect() var w = el.offsetWidth var h = el.offsetHeight var gap = 6 var vw = document.documentElement.clientWidth var vh = document.documentElement.clientHeight var align = el.getAttribute("data-align") || "start" var top = r.bottom + gap var originY = "top" if (top + h > vh - 8 && r.top - gap - h > 8) { top = r.top - gap - h originY = "bottom" } var left = align === "end" ? r.right - w : align === "center" ? r.left + r.width / 2 - w / 2 : r.left left = Math.max(8, Math.min(left, vw - w - 8)) var originX = Math.max(0, Math.min(w, r.left + r.width / 2 - left)) el.style.top = top + "px" el.style.left = left + "px" el.style.transformOrigin = originX + "px " + originY } function openFloat(el, trigger, kind) { if (openFloating) closeFloat(false) el.hidden = false position(trigger, el) show(el) trigger.setAttribute("aria-expanded", "true") openFloating = { el: el, trigger: trigger, kind: kind } if (kind === "menu") { var first = menuItems(el)[0] if (first) first.focus() } else { var focusable = $("input, textarea, select, button, [href], [tabindex]:not([tabindex='-1'])", el) if (focusable) focusable.focus() } } function closeFloat(returnFocus) { if (!openFloating) return var o = openFloating openFloating = null o.trigger.setAttribute("aria-expanded", "false") hide(o.el) if (returnFocus) o.trigger.focus() } function menuItems(menu) { return $$('[role="menuitem"]:not([disabled]), [role="menuitemcheckbox"]:not([disabled])', menu) } function toggle(trigger, attr, kind) { var el = document.getElementById(trigger.getAttribute(attr)) if (!el) return if (openFloating && openFloating.el === el) closeFloat(true) else openFloat(el, trigger, kind) } document.addEventListener("click", function (e) { var menuTrigger = e.target.closest("[data-s-menu]") if (menuTrigger) return toggle(menuTrigger, "data-s-menu", "menu") var popTrigger = e.target.closest("[data-s-popover]") if (popTrigger) return toggle(popTrigger, "data-s-popover", "popover") var item = e.target.closest('.s-menu [role="menuitem"]') if (item && openFloating) { openFloating.el.dispatchEvent( new CustomEvent("s-select", { detail: { item: item, value: item.getAttribute("data-value") || item.textContent.trim() }, bubbles: true }) ) closeFloat(true) return } if (openFloating && !openFloating.el.contains(e.target)) closeFloat(false) }) document.addEventListener("keydown", function (e) { // Arrow keys on a closed menu trigger open it. var trigger = e.target.closest && e.target.closest("[data-s-menu]") if (trigger && (e.key === "ArrowDown" || e.key === "ArrowUp") && !openFloating) { e.preventDefault() return toggle(trigger, "data-s-menu", "menu") } if (!openFloating) return if (e.key === "Escape") { e.preventDefault() return closeFloat(true) } if (openFloating.kind !== "menu") return var items = menuItems(openFloating.el) var i = items.indexOf(document.activeElement) var next = null if (e.key === "ArrowDown") next = items[(i + 1) % items.length] if (e.key === "ArrowUp") next = items[(i - 1 + items.length) % items.length] if (e.key === "Home") next = items[0] if (e.key === "End") next = items[items.length - 1] if (e.key === "Tab") return closeFloat(false) if (next) { e.preventDefault() next.focus() } }) window.addEventListener("resize", function () { if (openFloating) position(openFloating.trigger, openFloating.el) }) window.addEventListener( "scroll", function () { if (openFloating) position(openFloating.trigger, openFloating.el) }, true ) /* ---------------------------------------------------------------- * Combobox * ---------------------------------------------------------------- */ function comboParts(root) { return { input: $('input[role="combobox"]', root), list: $('[role="listbox"]', root), empty: $(".s-listbox__empty", root), } } function visibleOptions(list) { return $$('[role="option"]', list).filter(function (o) { return !o.hidden }) } function setActive(root, option) { var p = comboParts(root) $$('[role="option"]', p.list).forEach(function (o) { o.removeAttribute("data-active") }) if (option) { option.setAttribute("data-active", "true") p.input.setAttribute("aria-activedescendant", option.id) option.scrollIntoView({ block: "nearest" }) } else { p.input.removeAttribute("aria-activedescendant") } } function filterCombo(root) { var p = comboParts(root) var q = p.input.value.trim().toLowerCase() var any = false $$('[role="option"]', p.list).forEach(function (o) { var match = o.textContent.toLowerCase().indexOf(q) !== -1 o.hidden = !match if (match) any = true }) if (p.empty) p.empty.hidden = any setActive(root, visibleOptions(p.list)[0]) } function openCombo(root) { var p = comboParts(root) if (!p.list.hidden && p.list.getAttribute("data-state") === "open") return show(p.list) p.input.setAttribute("aria-expanded", "true") } function closeCombo(root) { var p = comboParts(root) if (p.list.hidden) return p.input.setAttribute("aria-expanded", "false") hide(p.list) } function chooseOption(root, option) { var p = comboParts(root) $$('[role="option"]', p.list).forEach(function (o) { o.setAttribute("aria-selected", o === option ? "true" : "false") }) p.input.value = option.textContent.trim() closeCombo(root) root.dispatchEvent( new CustomEvent("s-change", { detail: { value: option.getAttribute("data-value") || p.input.value }, bubbles: true }) ) } function initCombobox(root) { $$("[data-s-combobox]", root).forEach(function (combo, n) { var p = comboParts(combo) if (!p.input || !p.list) return if (!p.list.id) p.list.id = "s-listbox-" + n p.input.setAttribute("aria-controls", p.list.id) p.input.setAttribute("aria-expanded", "false") p.input.setAttribute("aria-autocomplete", "list") $$('[role="option"]', p.list).forEach(function (o, i) { if (!o.id) o.id = p.list.id + "-" + i o.classList.add("s-option") }) }) } document.addEventListener("input", function (e) { var root = e.target.closest && e.target.closest("[data-s-combobox]") if (!root) return openCombo(root) filterCombo(root) }) document.addEventListener("focusin", function (e) { var root = e.target.closest && e.target.closest("[data-s-combobox]") if (root && e.target.matches('input[role="combobox"]')) { openCombo(root) filterCombo(root) } }) document.addEventListener("mousedown", function (e) { var option = e.target.closest('[data-s-combobox] [role="option"]') if (option) { e.preventDefault() // keep focus in the input chooseOption(option.closest("[data-s-combobox]"), option) return } $$("[data-s-combobox]").forEach(function (root) { if (!root.contains(e.target)) closeCombo(root) }) }) document.addEventListener("keydown", function (e) { var root = e.target.closest && e.target.closest("[data-s-combobox]") if (!root) return var p = comboParts(root) var options = visibleOptions(p.list) var current = options.indexOf($('[data-active="true"]', p.list)) if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault() openCombo(root) var step = e.key === "ArrowDown" ? 1 : -1 setActive(root, options[(current + step + options.length) % options.length]) } else if (e.key === "Enter") { if (!p.list.hidden && options[current]) { e.preventDefault() chooseOption(root, options[current]) } } else if (e.key === "Escape") { closeCombo(root) } else if (e.key === "Tab") { closeCombo(root) } }) /* ---------------------------------------------------------------- * Toast * ---------------------------------------------------------------- */ var MAX_TOASTS = 3 function toaster() { var el = $(".s-toaster") if (!el) { el = document.createElement("ol") el.className = "s-toaster" el.setAttribute("aria-live", "polite") document.body.appendChild(el) } return el } function dismiss(toast) { if (toast._kGone) return toast._kGone = true clearTimeout(toast._kTimer) toast.setAttribute("data-state", "closed") setTimeout(function () { toast.remove() }, EXIT_MS) } Sabil.toast = function (options) { var o = typeof options === "string" ? { title: options } : options || {} var list = toaster() var toast = document.createElement("li") toast.className = "s-toast" + (o.variant === "destructive" ? " s-toast--destructive" : "") toast.setAttribute("role", o.variant === "destructive" ? "alert" : "status") var title = document.createElement("p") title.className = "s-toast__title" title.textContent = o.title || "" toast.appendChild(title) if (o.description) { var desc = document.createElement("p") desc.className = "s-toast__description" desc.textContent = o.description toast.appendChild(desc) } if (o.action) { var btn = document.createElement("button") btn.type = "button" btn.className = "s-btn s-btn--outline s-btn--sm" btn.textContent = o.action.label btn.addEventListener("click", function () { if (o.action.onClick) o.action.onClick() dismiss(toast) }) toast.appendChild(btn) } list.appendChild(toast) requestAnimationFrame(function () { requestAnimationFrame(function () { toast.setAttribute("data-state", "open") }) }) // Auto-dismiss, paused while hovered or while the tab is hidden. var duration = o.duration || 4000 var remaining = duration var started = Date.now() function start() { started = Date.now() toast._kTimer = setTimeout(function () { dismiss(toast) }, remaining) } function pause() { clearTimeout(toast._kTimer) remaining -= Date.now() - started } toast.addEventListener("mouseenter", pause) toast.addEventListener("mouseleave", start) document.addEventListener("visibilitychange", function () { if (toast._kGone) return if (document.hidden) pause() else start() }) start() var live = $$(".s-toast:not([data-state='closed'])", list) if (live.length > MAX_TOASTS) dismiss(live[0]) return { dismiss: function () { dismiss(toast) } } } document.addEventListener("click", function (e) { var t = e.target.closest("[data-s-toast]") if (t) { Sabil.toast({ title: t.getAttribute("data-s-toast"), description: t.getAttribute("data-s-toast-description") || undefined, variant: t.getAttribute("data-s-toast-variant") || undefined, }) } }) /* ---------------------------------------------------------------- * Init * ---------------------------------------------------------------- */ Sabil.init = function (root) { initTabs(root) initCombobox(root) } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", function () { Sabil.init() }) } else { Sabil.init() }})()