Skip to content

Instantly share code, notes, and snippets.

@andreasvirkus
Last active March 25, 2021 10:04
Show Gist options
  • Save andreasvirkus/5de84fa7d8674faa2717ae33da16ce3e to your computer and use it in GitHub Desktop.
Save andreasvirkus/5de84fa7d8674faa2717ae33da16ce3e to your computer and use it in GitHub Desktop.
Vanilla JS Toast component
.toaster {
display: block;
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: 16px;
width: 100%;
height: 0;
z-index: 11;
}
.toast {
display: flex;
position: fixed;
bottom: 0;
left: 50%;
align-items: center;
transform-origin: center;
will-change: transform;
transition: transform 300ms ease, opacity 300ms ease;
font-size: 14px;
padding-bottom: 10px;
}
.toast[aria-hidden='false'] {
animation: snackbar-show 300ms ease 1;
}
.toast[aria-hidden='true'] {
animation: snackbar-hide 300ms ease forwards 1;
}
.toast__container {
display: flex;
align-items: center;
position: relative;
padding: 10px 16px;
font-size: 14px;
min-width: 280px;
border-radius: 8px;
color: white;
font-weight: 500;
background-color: var(--toast-color, var(--brand));
box-shadow: var(--shadow-lg);
}
.toast__text {
flex: 1 1 auto;
font-size: 100%;
}
.toast__text:not(:last-child) {
padding-right: 8px;
}
.toast__button {
font-weight: 500;
border-bottom: 1px solid;
}
.toast__close {
position: relative;
display: block;
height: 24px;
width: 24px;
color: white;
border: none;
font-size: 24px;
line-height: 1;
transform: translateY(-1px);
z-index: 1;
}
.toast__close::before {
content: '';
position: absolute;
top: 1px;
left: 0;
height: 24px;
width: 24px;
background-color: var(--gray-90);
opacity: 0.2;
border-radius: 50%;
z-index: -1;
}
.toast__close:hover::before {
opacity: 0.4;
}
@keyframes snackbar-show {
from {
opacity: 0;
transform: translate3d(0, 100%, 0);
}
}
@keyframes snackbar-hide {
to {
opacity: 0;
transform: translateY(100%);
}
}
import './style.css'
interface ToastOptions {
status?: 'info' | 'error' | 'warning' | 'success'
message: string
actionLabel?: string
timeout?: number
maxStack?: number
callback?: () => void
}
export interface ToastResult {
destroy: () => void
}
const instances: Toast[] = []
let instanceStackStatus = true
const defaultMaxStack = 3
const defaultStatus = 'info'
const statusColorMap = {
info: '--brand-50',
error: '--red-50',
success: '--green-50',
warning: '--orange-50',
}
class Toast {
options: ToastOptions
wrapper: HTMLDivElement
el?: HTMLDivElement
private timeoutId?: number
private visibilityTimeoutId?: number
constructor(options: ToastOptions) {
this.options = Object.assign(
{},
{
status: defaultStatus,
timeout: 3000,
maxStack: defaultMaxStack,
actionLabel: '',
},
options,
)
this.wrapper = this.getWrapper()
this.insert()
instances.push(this)
this.stack()
}
getWrapper(): HTMLDivElement {
let wrapper = document.querySelector(`.toaster`) as HTMLDivElement
if (!wrapper) {
wrapper = document.createElement('div')
wrapper.className = `toaster`
document.body.appendChild(wrapper)
}
return wrapper
}
insert() {
const el = document.createElement('div')
el.className = 'toast'
el.setAttribute('aria-live', 'assertive')
el.setAttribute('aria-atomic', 'true')
el.setAttribute('aria-hidden', 'false')
el.style.setProperty('--toast-color', `var(${statusColorMap[this.options.status || defaultStatus]})`)
// Container for center-alignment and space between stacked toasts
const container = document.createElement('div')
container.className = 'toast__container'
el.appendChild(container)
const text = document.createElement('div')
text.className = 'toast__text'
if (typeof this.options.message === 'string') {
text.textContent = this.options.message
} else {
text.appendChild(this.options.message)
}
container.appendChild(text)
const { actionLabel, callback } = this.options
const button = document.createElement('button')
button.className = 'toast__button'
button.innerHTML = actionLabel || '×'
if (!actionLabel) button.classList.add('toast__close')
button.addEventListener('click', () => {
this.stopTimer()
if (callback) callback()
this.destroy()
})
container.appendChild(button)
this.startTimer()
el.addEventListener('mouseenter', () => this.expand())
el.addEventListener('mouseleave', () => this.stack())
this.el = el
this.wrapper.appendChild(el)
}
stack() {
instanceStackStatus = true
const positionInstances = instances
const l = positionInstances.length - 1
positionInstances.forEach((instance, i) => {
// Resume all instances' timers if applicable
instance.startTimer()
const { el } = instance
if (el) {
el.style.transform = `translate3d(0, -${(l - i) * 15}px, -${l - i}px) scale(${1 - 0.05 * (l - i)})`
const hidden = l - i >= (this.options.maxStack || defaultMaxStack)
this.toggleVisibility(el, hidden)
}
})
}
expand() {
instanceStackStatus = false
const positionInstances = instances
const l = positionInstances.length - 1
positionInstances.forEach((instance, i) => {
// Stop all instances' timers to prevent destroy
instance.stopTimer()
const { el } = instance
if (el) {
el.style.transform = `translate3d(0, -${(l - i) * el.clientHeight}px, 0) scale(1)`
const hidden = l - i >= (this.options.maxStack || defaultMaxStack)
this.toggleVisibility(el, hidden)
}
})
}
toggleVisibility(el: HTMLDivElement, hidden: boolean) {
if (hidden) {
this.visibilityTimeoutId = window.setTimeout(() => {
el.style.visibility = 'hidden'
}, 300)
el.style.opacity = '0'
} else {
if (this.visibilityTimeoutId) {
clearTimeout(this.visibilityTimeoutId)
this.visibilityTimeoutId = undefined
}
el.style.opacity = '1'
el.style.visibility = 'visible'
}
}
/**
* Destory the toast
*/
async destroy() {
const { el, wrapper } = this
if (el) {
// Animate the toast away.
el.setAttribute('aria-hidden', 'true')
await new Promise<void>((resolve) => el.addEventListener('animationend', () => resolve()))
wrapper.removeChild(el)
// Remove instance from the instances array
const positionInstances = instances
let index: number | undefined = undefined
for (let i = 0; i < positionInstances.length; i++) {
if (positionInstances[i].el === el) {
index = i
break
}
}
if (index !== undefined) positionInstances.splice(index, 1)
// Based on current status, refresh stack or expand style
if (instanceStackStatus) this.stack()
else this.expand()
}
}
startTimer() {
if (this.options.timeout && !this.timeoutId) {
this.timeoutId = self.setTimeout(() => this.destroy(), this.options.timeout)
}
}
stopTimer() {
if (this.timeoutId) {
clearTimeout(this.timeoutId)
this.timeoutId = undefined
}
}
}
export const toast = (options: ToastOptions) => new Toast(options)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment