Como criar um simples SPA com javascript e bootstrap 5
Simples SPA com javascript e bootstrap 5

Tutorial: SPA Moderna com Vanilla JS e ES Modules
Estrutura do Projeto

Conceitos Aplicados
-
ES Modules nativos — cada arquivo usa
export/import; o HTML carrega apenas<script type="module" src="app.js">e o browser resolve a árvore de dependências. -
Roteamento por hash — a URL
#home,#about,#contactcontrola qual página é exibida. O app.js ouve o eventohashchangee renderiza o template correspondente. -
Separação de responsabilidades — cada página é uma função que retorna HTML (template literal). Lógica específica (ex: bindContactForm) fica junto da página que a utiliza.
-
CSS externo com custom properties — variáveis como
--spa-primarye--spa-gradientcentralizam cores e facilitam customização. -
Dark mode nativo — usa data-bs-theme do Bootstrap 5.3, com persistência via localStorage e detecção da preferência do sistema (
prefers-color-scheme). -
Transições de página — classe .page-exit aplica fade+slide antes de trocar o conteúdo; animações
.fade-upescalonadas nos elementos internos. -
Zero dependências de build — funciona direto no browser, sem bundler, transpiler ou framework. Apenas Bootstrap via C
app.js
import { homePage } from './pages/home.js';
import { aboutPage } from './pages/about.js';
import { contactPage, bindContactForm } from './pages/contact.js';
import { notFoundPage } from './pages/404.js';
import { showToast } from './utils/toast.js';
// ??? Route Registry ?????????????????????????????????????????
const routes = {
home: homePage,
about: aboutPage,
contact: contactPage,
404: notFoundPage
};
// Hooks executados após renderizar cada página
const afterRender = {
contact: bindContactForm
};
// ??? Router ??????????????????????????????????????????????????
const contentDiv = document.getElementById('app-content');
let currentPage = null;
function navigateTo(page) {
if (page === currentPage) return;
currentPage = page;
// Animate out
contentDiv.classList.add('page-exit');
setTimeout(() => {
const renderer = routes[page] || routes['404'];
contentDiv.innerHTML = renderer();
contentDiv.classList.remove('page-exit');
window.scrollTo({ top: 0, behavior: 'smooth' });
// Update active nav link
document.querySelectorAll('.nav-link[data-page]').forEach(link => {
link.classList.toggle('active', link.dataset.page === page);
});
// Run page-specific hooks
if (afterRender[page]) afterRender[page]();
// Bind internal SPA links inside page content
bindInternalLinks(contentDiv);
}, 250);
}
function bindInternalLinks(root) {
root.querySelectorAll('a[data-page]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = link.dataset.page;
});
});
}
// ??? Theme Toggle ???????????????????????????????????????????
const themeBtn = document.getElementById('themeToggle');
const htmlEl = document.documentElement;
function applyTheme(theme) {
htmlEl.setAttribute('data-bs-theme', theme);
themeBtn.innerHTML = theme === 'dark'
? '<i class="bi bi-sun-fill"></i>'
: '<i class="bi bi-moon-stars-fill"></i>';
localStorage.setItem('spa-theme', theme);
}
const savedTheme = localStorage.getItem('spa-theme')
|| (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
applyTheme(savedTheme);
themeBtn.addEventListener('click', () => {
applyTheme(htmlEl.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark');
});
// ??? Hash Routing ???????????????????????????????????????????
function handleRoute() {
const hash = window.location.hash.replace('#', '') || 'home';
navigateTo(hash);
}
window.addEventListener('hashchange', handleRoute);
document.querySelectorAll('.nav-link[data-page]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = link.dataset.page;
const navCollapse = document.getElementById('navbarNav');
const bsCollapse = bootstrap.Collapse.getInstance(navCollapse);
if (bsCollapse) bsCollapse.hide();
});
});
// Initial load
handleRoute();
index.html
<!DOCTYPE html>
<html lang="pt-BR" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPA Moderna</title>
<!-- Bootstrap 5.3 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- App CSS -->
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg sticky-top">
<div class="container">
<a class="navbar-brand" href="#home">
<i class="bi bi-hexagon-fill me-1"></i> SPA
</a>
<div class="d-flex align-items-center gap-2 order-lg-last">
<button class="btn-theme" id="themeToggle" aria-label="Alternar tema">
<i class="bi bi-moon-stars-fill"></i>
</button>
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false"
aria-label="Abrir menu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto gap-1">
<li class="nav-item">
<a class="nav-link active" href="#home" data-page="home">
<i class="bi bi-house-door"></i> Home
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#about" data-page="about">
<i class="bi bi-info-circle"></i> Sobre
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#contact" data-page="contact">
<i class="bi bi-envelope"></i> Contato
</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Content -->
<main class="container py-4 py-lg-5">
<div id="app-content"></div>
</main>
<!-- Footer -->
<footer class="py-4">
<div class="container">
<div class="row align-items-center g-3">
<div class="col-md-6 text-center text-md-start">
<span class="text-secondary small">© 2026 SPA Moderna. Todos os direitos reservados.</span>
</div>
<div class="col-md-6 text-center text-md-end">
<a href="#" class="me-3"><i class="bi bi-github fs-5"></i></a>
<a href="#" class="me-3"><i class="bi bi-twitter-x fs-5"></i></a>
<a href="#"><i class="bi bi-linkedin fs-5"></i></a>
</div>
</div>
</div>
</footer>
<!-- Toast container -->
<div class="toast-container" id="toastContainer"></div>
<!-- Bootstrap 5.3 Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- App (ES Module) -->
<script type="module" src="app.js"></script>
</body>
</html>
/pages/404.js
export const notFoundPage = () => `
<div class="text-center py-5 fade-up">
<div class="display-1 fw-bold" style="background:var(--spa-gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">404</div>
<h3 class="fw-bold mt-2 mb-3">Página não encontrada</h3>
<p class="text-secondary mb-4">A página que você procura não existe ou foi movida.</p>
<a href="#home" data-page="home" class="btn btn-primary">
<i class="bi bi-house me-2"></i>Voltar ao início
</a>
</div>
`;
/pages/about.js
export const aboutPage = () => `
<div class="row g-5 align-items-center mb-5">
<div class="col-lg-6 fade-up">
<span class="badge rounded-pill text-bg-primary bg-opacity-10 text-primary mb-3 px-3 py-2"
style="background: rgba(99,102,241,.1) !important; color: var(--spa-primary) !important">
<i class="bi bi-stars me-1"></i> Sobre nós
</span>
<h2 class="fw-bold mb-3" style="letter-spacing:-0.03em">
Criamos experiências digitais excepcionais
</h2>
<p class="text-secondary mb-4">
Somos apaixonados por tecnologia e design. Nossa missão é transformar ideias em
aplicações web modernas, performáticas e acessíveis para todos.
</p>
<a href="#contact" data-page="contact" class="btn btn-primary">
Fale conosco <i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
<div class="col-lg-6 fade-up" style="animation-delay:0.1s">
<div class="p-4">
<h5 class="fw-semibold mb-4"><i class="bi bi-clock-history me-2 text-primary"></i>Nossa Trajetória</h5>
${[
{ year: '2020', text: 'Início do projeto com foco em soluções web modernas.' },
{ year: '2022', text: 'Expansão da equipe e adoção de metodologias ágeis.' },
{ year: '2024', text: 'Migração para arquitetura baseada em SPA e componentização.' },
{ year: '2026', text: 'Lançamento da versão totalmente modernizada.' }
].map(t => `
<div class="timeline-item">
<h6>${t.year}</h6>
<p class="text-secondary small mb-0">${t.text}</p>
</div>
`).join('')}
</div>
</div>
</div>
<h4 class="fw-bold text-center mb-4 fade-up">Nossa Equipe</h4>
<div class="row g-4 mb-3">
${[
{ initials: 'AS', name: 'Ana Silva', role: 'CEO & Design', icon: 'bi-palette2' },
{ initials: 'MR', name: 'Marcos Reis', role: 'Full-stack Dev', icon: 'bi-code-slash' },
{ initials: 'JF', name: 'Julia Fontes', role: 'UX Research', icon: 'bi-person-hearts' }
].map((m, i) => `
<div class="col-md-4 fade-up" style="animation-delay:${i * 0.1}s">
<div class="team-card">
<div class="team-avatar">${m.initials}</div>
<h6 class="fw-semibold mb-1">${m.name}</h6>
<span class="text-secondary small"><i class="bi ${m.icon} me-1"></i>${m.role}</span>
</div>
</div>
`).join('')}
</div>
`;
/pages/contact.js
import { showToast } from '../utils/toast.js';
export const contactPage = () => `
<div class="row g-5">
<div class="col-lg-5 fade-up">
<span class="badge rounded-pill text-bg-primary bg-opacity-10 text-primary mb-3 px-3 py-2"
style="background: rgba(99,102,241,.1) !important; color: var(--spa-primary) !important">
<i class="bi bi-chat-dots me-1"></i> Contato
</span>
<h2 class="fw-bold mb-3" style="letter-spacing:-0.03em">Vamos conversar?</h2>
<p class="text-secondary mb-4">
Tem uma ideia, projeto ou dúvida? Preencha o formulário ou use um dos canais abaixo.
Respondemos em até 24 horas.
</p>
<div class="d-flex flex-column gap-3">
${[
{ icon: 'bi-envelope-at', label: 'E-mail', value: 'contato@exemplo.com' },
{ icon: 'bi-telephone', label: 'Telefone', value: '+55 (11) 99999-0000' },
{ icon: 'bi-geo-alt', label: 'Localização', value: 'São Paulo, SP — Brasil' }
].map(c => `
<div class="contact-info-card">
<div class="icon-wrapper"><i class="bi ${c.icon}"></i></div>
<div>
<div class="small text-secondary">${c.label}</div>
<div class="fw-medium">${c.value}</div>
</div>
</div>
`).join('')}
</div>
</div>
<div class="col-lg-7 fade-up" style="animation-delay:0.1s">
<div class="p-4 p-lg-5 rounded-4" style="border:1px solid rgba(0,0,0,0.06); background: var(--bs-body-bg)">
<form id="contactForm" novalidate>
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label small fw-medium">Nome</label>
<input type="text" class="form-control" placeholder="Seu nome" required>
</div>
<div class="col-sm-6">
<label class="form-label small fw-medium">E-mail</label>
<input type="email" class="form-control" placeholder="seu@email.com" required>
</div>
<div class="col-12">
<label class="form-label small fw-medium">Assunto</label>
<select class="form-select">
<option selected disabled>Selecione um assunto</option>
<option>Projeto novo</option>
<option>Consultoria</option>
<option>Parceria</option>
<option>Outro</option>
</select>
</div>
<div class="col-12">
<label class="form-label small fw-medium">Mensagem</label>
<textarea class="form-control" rows="4" placeholder="Descreva como podemos ajudar…" required></textarea>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-send me-2"></i>Enviar mensagem
</button>
</div>
</div>
</form>
</div>
</div>
</div>
`;
export function bindContactForm() {
const form = document.getElementById('contactForm');
if (!form) return;
form.addEventListener('submit', (e) => {
e.preventDefault();
if (!form.checkValidity()) {
form.classList.add('was-validated');
return;
}
showToast('Mensagem enviada!', 'Obrigado pelo contato. Responderemos em breve.', 'bi-check-circle-fill text-success');
form.reset();
form.classList.remove('was-validated');
});
}
/pages/home.js
export const homePage = () => `
<section class="hero text-center mb-5 fade-up">
<h1 class="mb-3">Construa algo<br>incrível hoje</h1>
<p class="mx-auto mb-4" style="max-width:540px">
Uma Single Page Application moderna, rápida e elegante — feita com Bootstrap 5.3, ícones,
dark mode e transições suaves.
</p>
<a href="#about" data-page="about" class="btn btn-outline-light btn-lg">
Saiba mais <i class="bi bi-arrow-right ms-1"></i>
</a>
</section>
<div class="row g-4 mb-5">
${[
{ icon: 'bi-lightning-charge', title: 'Ultra Rápido', desc: 'Carregamento instantâneo sem reloads de página. Transições suaves entre seções.' },
{ icon: 'bi-palette', title: 'Design Moderno', desc: 'Interface clean com gradientes, glassmorphism e tema escuro automático.' },
{ icon: 'bi-phone', title: 'Responsivo', desc: 'Layout adaptável para qualquer dispositivo — desktop, tablet ou mobile.' },
{ icon: 'bi-shield-check', title: 'Boas Práticas', desc: 'Código semântico, acessível e seguindo padrões modernos da web.' },
{ icon: 'bi-gear', title: 'Fácil de Customizar', desc: 'Custom properties CSS, arquitetura simples e extensível.' },
{ icon: 'bi-moon-stars', title: 'Dark Mode', desc: 'Alternância de tema com um clique, respeitando a preferência do sistema.' }
].map((f, i) => `
<div class="col-md-6 col-lg-4 fade-up" style="animation-delay:${i * 0.08}s">
<div class="feature-card">
<div class="feature-icon"><i class="bi ${f.icon}"></i></div>
<h5>${f.title}</h5>
<p class="mb-0">${f.desc}</p>
</div>
</div>
`).join('')}
</div>
<div class="row g-4 text-center mb-3">
${[
{ value: '99.9%', label: 'Uptime' },
{ value: '< 50ms', label: 'Latência' },
{ value: '100', label: 'Lighthouse Score' },
{ value: '0', label: 'Dependências externas' }
].map((s, i) => `
<div class="col-6 col-lg-3 fade-up" style="animation-delay:${(i + 6) * 0.08}s">
<div class="stat-value">${s.value}</div>
<div class="stat-label">${s.label}</div>
</div>
`).join('')}
</div>
`;
utils/toast.js
export function showToast(title, body, iconClass = 'bi-info-circle-fill') {
const container = document.getElementById('toastContainer');
const id = 'toast-' + Date.now();
const html = `
<div id="${id}" class="toast align-items-center border-0 shadow-lg" role="alert"
aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<i class="bi ${iconClass} me-2"></i>
<strong class="me-auto">${title}</strong>
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">${body}</div>
</div>`;
container.insertAdjacentHTML('beforeend', html);
const toastEl = document.getElementById(id);
const toast = new bootstrap.Toast(toastEl, { delay: 4000 });
toast.show();
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
}
style.css
:root {
--spa-primary: #6366f1 ;
--spa-primary-hover: #4f46e5 ;
--spa-accent: #06b6d4 ;
--spa-gradient: linear-gradient(135deg, #6366f1 , #06b6d4 );
--spa-transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--spa-radius: 1rem;
--spa-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--spa-shadow-lg: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}
* {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
/* Navbar */
.navbar {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: rgba(255, 255, 255, 0.8) !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
padding: 0.75rem 0;
transition: var(--spa-transition);
}
[data-bs-theme="dark"] .navbar {
background: rgba(30, 30, 40, 0.85) !important;
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.navbar-brand {
font-weight: 700;
font-size: 1.35rem;
background: var(--spa-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.025em;
}
.nav-link {
font-weight: 500;
font-size: 0.925rem;
padding: 0.5rem 1rem !important;
border-radius: 0.5rem;
transition: var(--spa-transition);
position: relative;
color: var(--bs-body-color) !important;
}
.nav-link:hover,
.nav-link.active {
color: var(--spa-primary) !important;
background: rgba(99, 102, 241, 0.08);
}
.nav-link.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 1.25rem;
height: 2px;
background: var(--spa-gradient);
border-radius: 2px;
}
.nav-link i {
margin-right: 0.35rem;
font-size: 1rem;
}
/* Theme toggle */
.btn-theme {
border: none;
background: transparent;
font-size: 1.2rem;
padding: 0.4rem 0.6rem;
border-radius: 0.5rem;
transition: var(--spa-transition);
color: var(--bs-body-color);
cursor: pointer;
}
.btn-theme:hover {
background: rgba(99, 102, 241, 0.1);
color: var(--spa-primary);
}
/* Page transitions */
#app-content {
flex: 1;
opacity: 1;
transform: translateY(0);
transition: opacity 0.25s ease, transform 0.25s ease;
}
#app-content.page-exit {
opacity: 0;
transform: translateY(12px);
}
/* Hero Section */
.hero {
background: var(--spa-gradient);
border-radius: var(--spa-radius);
padding: 4rem 2rem;
color: #fff ;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.06'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
.hero h1 {
font-weight: 700;
font-size: 2.75rem;
letter-spacing: -0.035em;
position: relative;
}
.hero p {
font-size: 1.15rem;
opacity: 0.9;
position: relative;
}
.hero .btn {
position: relative;
}
/* Cards */
.feature-card {
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: var(--spa-radius);
padding: 2rem;
transition: var(--spa-transition);
background: var(--bs-body-bg);
height: 100%;
}
[data-bs-theme="dark"] .feature-card {
border-color: rgba(255, 255, 255, 0.08);
}
.feature-card:hover {
transform: translateY(-4px);
box-shadow: var(--spa-shadow-lg);
border-color: rgba(99, 102, 241, 0.25);
}
.feature-icon {
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.75rem;
background: rgba(99, 102, 241, 0.1);
color: var(--spa-primary);
font-size: 1.3rem;
margin-bottom: 1rem;
}
.feature-card h5 {
font-weight: 600;
letter-spacing: -0.01em;
}
.feature-card p {
color: var(--bs-secondary-color);
font-size: 0.925rem;
line-height: 1.6;
}
/* Stats */
.stat-value {
font-size: 2.25rem;
font-weight: 700;
background: var(--spa-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
font-size: 0.85rem;
color: var(--bs-secondary-color);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* About page */
.timeline-item {
position: relative;
padding-left: 2rem;
padding-bottom: 2rem;
border-left: 2px solid rgba(99, 102, 241, 0.2);
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-item::before {
content: '';
position: absolute;
left: -6px;
top: 4px;
width: 10px;
height: 10px;
background: var(--spa-primary);
border-radius: 50%;
}
.timeline-item h6 {
font-weight: 600;
color: var(--spa-primary);
}
/* Team */
.team-card {
text-align: center;
padding: 2rem 1.5rem;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: var(--spa-radius);
transition: var(--spa-transition);
}
[data-bs-theme="dark"] .team-card {
border-color: rgba(255, 255, 255, 0.08);
}
.team-card:hover {
box-shadow: var(--spa-shadow-lg);
transform: translateY(-4px);
}
.team-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--spa-gradient);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
font-size: 2rem;
color: #fff ;
font-weight: 600;
}
/* Contact */
.contact-info-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: var(--spa-radius);
transition: var(--spa-transition);
}
[data-bs-theme="dark"] .contact-info-card {
border-color: rgba(255, 255, 255, 0.08);
}
.contact-info-card:hover {
border-color: rgba(99, 102, 241, 0.3);
box-shadow: var(--spa-shadow);
}
.contact-info-card .icon-wrapper {
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.75rem;
background: rgba(99, 102, 241, 0.1);
color: var(--spa-primary);
font-size: 1.2rem;
flex-shrink: 0;
}
.form-control,
.form-select {
border-radius: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.1);
transition: var(--spa-transition);
}
.form-control:focus,
.form-select:focus {
border-color: var(--spa-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
.btn-primary {
background: var(--spa-gradient);
border: none;
border-radius: 0.75rem;
padding: 0.75rem 2rem;
font-weight: 600;
transition: var(--spa-transition);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 8px 16px rgba(99, 102, 241, 0.3);
}
.btn-outline-light {
border-radius: 0.75rem;
padding: 0.65rem 1.75rem;
font-weight: 600;
border-width: 2px;
}
/* Footer */
footer {
border-top: 1px solid rgba(0, 0, 0, 0.06);
margin-top: auto;
}
[data-bs-theme="dark"] footer {
border-top-color: rgba(255, 255, 255, 0.06);
}
footer a {
color: var(--bs-secondary-color);
text-decoration: none;
transition: var(--spa-transition);
}
footer a:hover {
color: var(--spa-primary);
}
/* Animate elements on page load */
.fade-up {
animation: fadeUp 0.5s ease forwards;
opacity: 0;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-up:nth-child(2) { animation-delay: 0.1s; }
.fade-up:nth-child(3) { animation-delay: 0.2s; }
.fade-up:nth-child(4) { animation-delay: 0.3s; }
/* Toast */
.toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.5);
}
Compartilhe este artigo: