A Pen by Tom Hermans on CodePen.
Created
May 27, 2025 20:57
-
-
Save tomhermans/20166b514a86ffe12ef8193d4960788b to your computer and use it in GitHub Desktop.
Untitled
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script src="https://cdnjs.cloudflare.com/ajax/libs/svelte/4.2.0/svelte.min.js"></script> | |
<div id="app"></div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Token store using Svelte runes equivalent (state management) | |
class TokenStore { | |
constructor() { | |
this.tokens = { | |
colors: { | |
blue: { | |
100: '#e6f3ff', 200: '#bde0ff', 300: '#94d3ff', 400: '#5eb8ff', | |
500: '#2196f3', 600: '#1976d2', 700: '#1565c0', 800: '#0d47a1', 900: '#003d7a' | |
}, | |
neutral: { | |
100: '#f8f9fa', 200: '#e9ecef', 300: '#dee2e6', 400: '#ced4da', | |
500: '#adb5bd', 600: '#6c757d', 700: '#495057', 800: '#343a40', 900: '#212529' | |
}, | |
red: { | |
100: '#ffebee', 200: '#ffcdd2', 300: '#ef9a9a', 400: '#e57373', | |
500: '#f44336', 600: '#e53935', 700: '#d32f2f', 800: '#c62828', 900: '#b71c1c' | |
} | |
}, | |
sizes: { | |
xs: '0.25rem', sm: '0.5rem', md: '1rem', | |
lg: '1.5rem', xl: '2rem', '2xl': '3rem', '3xl': '4rem' | |
}, | |
fonts: { | |
sans: 'system-ui, -apple-system, sans-serif', | |
serif: 'Georgia, serif', | |
mono: '"Courier New", monospace' | |
}, | |
weights: { | |
normal: '400', medium: '500', semibold: '600', bold: '700' | |
} | |
}; | |
this.sectionOverrides = {}; | |
this.selectedSection = null; | |
this.panelOpen = false; | |
this.activeTab = 'colors'; | |
this.subscribers = []; | |
this.updateRootProperties(); | |
} | |
subscribe(callback) { | |
this.subscribers.push(callback); | |
return () => { | |
this.subscribers = this.subscribers.filter(sub => sub !== callback); | |
}; | |
} | |
notify() { | |
this.subscribers.forEach(callback => callback()); | |
} | |
updateToken(category, key, value) { | |
if (this.tokens[category]) { | |
this.tokens[category][key] = value; | |
this.updateRootProperties(); | |
this.notify(); | |
} | |
} | |
updateColorToken(color, shade, value) { | |
if (this.tokens.colors[color]) { | |
this.tokens.colors[color][shade] = value; | |
this.updateRootProperties(); | |
this.notify(); | |
} | |
} | |
setSectionOverride(sectionId, property, value) { | |
if (!this.sectionOverrides[sectionId]) { | |
this.sectionOverrides[sectionId] = {}; | |
} | |
this.sectionOverrides[sectionId][property] = value; | |
this.applySectionStyles(sectionId); | |
this.notify(); | |
} | |
selectSection(sectionId) { | |
this.selectedSection = sectionId; | |
this.panelOpen = true; | |
this.notify(); | |
} | |
closePanel() { | |
this.panelOpen = false; | |
this.selectedSection = null; | |
this.notify(); | |
} | |
setActiveTab(tab) { | |
this.activeTab = tab; | |
this.notify(); | |
} | |
updateRootProperties() { | |
const root = document.documentElement; | |
// Update color tokens | |
Object.entries(this.tokens.colors).forEach(([color, shades]) => { | |
Object.entries(shades).forEach(([shade, value]) => { | |
root.style.setProperty(`--clr-${color}-${shade}`, value); | |
}); | |
}); | |
// Update size tokens | |
Object.entries(this.tokens.sizes).forEach(([key, value]) => { | |
root.style.setProperty(`--size-${key}`, value); | |
}); | |
// Update font tokens | |
Object.entries(this.tokens.fonts).forEach(([key, value]) => { | |
root.style.setProperty(`--font-${key}`, value); | |
}); | |
// Update weight tokens | |
Object.entries(this.tokens.weights).forEach(([key, value]) => { | |
root.style.setProperty(`--weight-${key}`, value); | |
}); | |
} | |
applySectionStyles(sectionId) { | |
const element = document.getElementById(sectionId); | |
if (!element || !this.sectionOverrides[sectionId]) return; | |
const overrides = this.sectionOverrides[sectionId]; | |
Object.entries(overrides).forEach(([property, value]) => { | |
element.style.setProperty(property, value); | |
}); | |
} | |
} | |
const store = new TokenStore(); | |
function render() { | |
const app = document.getElementById('app'); | |
app.innerHTML = ` | |
<div class="app"> | |
<!-- Global Controls --> | |
<div class="global-controls"> | |
<h3>Global Tokens</h3> | |
<div class="control-group"> | |
<label>Primary Blue Shade</label> | |
<select id="global-blue-primary"> | |
${Object.keys(store.tokens.colors.blue).map(shade => | |
`<option value="${shade}" ${shade === '500' ? 'selected' : ''}>Blue ${shade}</option>` | |
).join('')} | |
</select> | |
</div> | |
<div class="control-group"> | |
<label>Base Font</label> | |
<select id="global-font"> | |
${Object.entries(store.tokens.fonts).map(([key, value]) => | |
`<option value="${key}" ${key === 'sans' ? 'selected' : ''}>${key}</option>` | |
).join('')} | |
</select> | |
</div> | |
<div class="control-group"> | |
<label>Base Size Scale</label> | |
<select id="global-size"> | |
${Object.keys(store.tokens.sizes).map(size => | |
`<option value="${size}" ${size === 'md' ? 'selected' : ''}>${size}</option>` | |
).join('')} | |
</select> | |
</div> | |
</div> | |
<!-- Section Panel --> | |
<div class="section-panel ${store.panelOpen ? '' : 'closed'}"> | |
<div class="panel-header"> | |
<h3>Section: ${store.selectedSection || 'None'}</h3> | |
<button class="close-btn" onclick="store.closePanel()">×</button> | |
</div> | |
<div class="panel-tabs"> | |
<button class="tab-btn ${store.activeTab === 'colors' ? 'active' : ''}" | |
onclick="store.setActiveTab('colors')">Colors</button> | |
<button class="tab-btn ${store.activeTab === 'sizes' ? 'active' : ''}" | |
onclick="store.setActiveTab('sizes')">Sizes</button> | |
<button class="tab-btn ${store.activeTab === 'fonts' ? 'active' : ''}" | |
onclick="store.setActiveTab('fonts')">Fonts</button> | |
</div> | |
<div class="tab-content ${store.activeTab === 'colors' ? 'active' : ''}"> | |
<div class="control-grid"> | |
<div class="control-item"> | |
<label>Background</label> | |
<select onchange="setSectionColor('background-color', this.value)"> | |
<option value="">Default</option> | |
${Object.entries(store.tokens.colors).map(([color, shades]) => | |
Object.keys(shades).map(shade => | |
`<option value="var(--clr-${color}-${shade})"> | |
<span class="color-swatch" style="background: var(--clr-${color}-${shade})"></span> | |
${color} ${shade} | |
</option>` | |
).join('') | |
).join('')} | |
</select> | |
</div> | |
<div class="control-item"> | |
<label>Text Color</label> | |
<select onchange="setSectionColor('color', this.value)"> | |
<option value="">Default</option> | |
${Object.entries(store.tokens.colors).map(([color, shades]) => | |
Object.keys(shades).map(shade => | |
`<option value="var(--clr-${color}-${shade})">${color} ${shade}</option>` | |
).join('') | |
).join('')} | |
</select> | |
</div> | |
<div class="control-item"> | |
<label>Border Color</label> | |
<select onchange="setSectionColor('border-color', this.value)"> | |
<option value="">Default</option> | |
${Object.entries(store.tokens.colors).map(([color, shades]) => | |
Object.keys(shades).map(shade => | |
`<option value="var(--clr-${color}-${shade})">${color} ${shade}</option>` | |
).join('') | |
).join('')} | |
</select> | |
</div> | |
</div> | |
</div> | |
<div class="tab-content ${store.activeTab === 'sizes' ? 'active' : ''}"> | |
<div class="control-grid"> | |
<div class="control-item"> | |
<label>Padding</label> | |
<select onchange="setSectionSize('padding', this.value)"> | |
<option value="">Default</option> | |
${Object.keys(store.tokens.sizes).map(size => | |
`<option value="var(--size-${size})">${size}</option>` | |
).join('')} | |
</select> | |
</div> | |
<div class="control-item"> | |
<label>Margin</label> | |
<select onchange="setSectionSize('margin-bottom', this.value)"> | |
<option value="">Default</option> | |
${Object.keys(store.tokens.sizes).map(size => | |
`<option value="var(--size-${size})">${size}</option>` | |
).join('')} | |
</select> | |
</div> | |
<div class="control-item"> | |
<label>Border Radius</label> | |
<select onchange="setSectionSize('border-radius', this.value)"> | |
<option value="">Default</option> | |
${Object.keys(store.tokens.sizes).map(size => | |
`<option value="var(--size-${size})">${size}</option>` | |
).join('')} | |
</select> | |
</div> | |
<div class="control-item"> | |
<label>Border Width</label> | |
<select onchange="setSectionSize('border-width', this.value)"> | |
<option value="">Default</option> | |
${Object.keys(store.tokens.sizes).map(size => | |
`<option value="var(--size-${size})">${size}</option>` | |
).join('')} | |
</select> | |
</div> | |
</div> | |
</div> | |
<div class="tab-content ${store.activeTab === 'fonts' ? 'active' : ''}"> | |
<div class="control-grid"> | |
<div class="control-item"> | |
<label>Font Family</label> | |
<select onchange="setSectionFont('font-family', this.value)"> | |
<option value="">Default</option> | |
${Object.entries(store.tokens.fonts).map(([key, value]) => | |
`<option value="var(--font-${key})">${key}</option>` | |
).join('')} | |
</select> | |
</div> | |
<div class="control-item"> | |
<label>Font Weight</label> | |
<select onchange="setSectionFont('font-weight', this.value)"> | |
<option value="">Default</option> | |
${Object.entries(store.tokens.weights).map(([key, value]) => | |
`<option value="var(--weight-${key})">${key}</option>` | |
).join('')} | |
</select> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Main Content --> | |
<div class="main-content"> | |
<div class="content-section ${store.selectedSection === 'section-1' ? 'selected' : ''}" | |
id="section-1" onclick="store.selectSection('section-1')"> | |
<div class="section-label">Section 1</div> | |
<h2>Hero Section</h2> | |
<p>This is a hero section that uses the design tokens. Click to customize its appearance using the section panel above.</p> | |
<p>Background uses --clr-blue-200 by default, but can be overridden per section.</p> | |
</div> | |
<div class="content-section ${store.selectedSection === 'section-2' ? 'selected' : ''}" | |
id="section-2" onclick="store.selectSection('section-2')" | |
style="background-color: var(--clr-neutral-200);"> | |
<div class="section-label">Section 2</div> | |
<h2>Content Section</h2> | |
<p>This section starts with a different background (--clr-neutral-200). You can override any property through the panel.</p> | |
<p>Changes to global tokens affect all sections unless specifically overridden.</p> | |
</div> | |
<div class="content-section ${store.selectedSection === 'section-3' ? 'selected' : ''}" | |
id="section-3" onclick="store.selectSection('section-3')" | |
style="background-color: var(--clr-red-100); font-family: var(--font-serif);"> | |
<div class="section-label">Section 3</div> | |
<h2>Featured Section</h2> | |
<p>This section demonstrates different default styling - red background and serif font.</p> | |
<p>Try changing the global font tokens to see how it affects sections that haven't been overridden.</p> | |
</div> | |
</div> | |
</div> | |
`; | |
// Apply existing section overrides | |
Object.keys(store.sectionOverrides).forEach(sectionId => { | |
store.applySectionStyles(sectionId); | |
}); | |
} | |
// Global helper functions | |
window.setSectionColor = function(property, value) { | |
if (store.selectedSection && value) { | |
store.setSectionOverride(store.selectedSection, property, value); | |
} | |
}; | |
window.setSectionSize = function(property, value) { | |
if (store.selectedSection && value) { | |
store.setSectionOverride(store.selectedSection, property, value); | |
} | |
}; | |
window.setSectionFont = function(property, value) { | |
if (store.selectedSection && value) { | |
store.setSectionOverride(store.selectedSection, property, value); | |
} | |
}; | |
// Global controls event listeners | |
document.addEventListener('change', function(e) { | |
if (e.target.id === 'global-blue-primary') { | |
// This would update the primary blue used throughout | |
store.updateColorToken('blue', '500', store.tokens.colors.blue[e.target.value]); | |
} else if (e.target.id === 'global-font') { | |
// Update the default font | |
document.documentElement.style.setProperty('--font-sans', store.tokens.fonts[e.target.value]); | |
} | |
}); | |
// Subscribe to store changes and re-render | |
store.subscribe(render); | |
// Initial render | |
render(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
:root { | |
/* Default tokens - will be overridden by JavaScript */ | |
--clr-blue-100: #e6f3ff; | |
--clr-blue-200: #bde0ff; | |
--clr-blue-300: #94d3ff; | |
--clr-blue-400: #5eb8ff; | |
--clr-blue-500: #2196f3; | |
--clr-blue-600: #1976d2; | |
--clr-blue-700: #1565c0; | |
--clr-blue-800: #0d47a1; | |
--clr-blue-900: #003d7a; | |
--clr-neutral-100: #f8f9fa; | |
--clr-neutral-200: #e9ecef; | |
--clr-neutral-300: #dee2e6; | |
--clr-neutral-400: #ced4da; | |
--clr-neutral-500: #adb5bd; | |
--clr-neutral-600: #6c757d; | |
--clr-neutral-700: #495057; | |
--clr-neutral-800: #343a40; | |
--clr-neutral-900: #212529; | |
--clr-red-100: #ffebee; | |
--clr-red-200: #ffcdd2; | |
--clr-red-300: #ef9a9a; | |
--clr-red-400: #e57373; | |
--clr-red-500: #f44336; | |
--clr-red-600: #e53935; | |
--clr-red-700: #d32f2f; | |
--clr-red-800: #c62828; | |
--clr-red-900: #b71c1c; | |
--size-xs: 0.25rem; | |
--size-sm: 0.5rem; | |
--size-md: 1rem; | |
--size-lg: 1.5rem; | |
--size-xl: 2rem; | |
--size-2xl: 3rem; | |
--size-3xl: 4rem; | |
--font-sans: system-ui, -apple-system, sans-serif; | |
--font-serif: Georgia, serif; | |
--font-mono: "Courier New", monospace; | |
--weight-normal: 400; | |
--weight-medium: 500; | |
--weight-semibold: 600; | |
--weight-bold: 700; | |
} | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
} | |
body { | |
font-family: var(--font-sans); | |
background-color: var(--clr-neutral-100); | |
color: var(--clr-neutral-800); | |
line-height: 1.6; | |
} | |
.app { | |
min-height: 100vh; | |
position: relative; | |
} | |
/* Global Controls */ | |
.global-controls { | |
position: fixed; | |
top: 1rem; | |
left: 1rem; | |
background: white; | |
border: 1px solid var(--clr-neutral-300); | |
border-radius: var(--size-sm); | |
padding: var(--size-md); | |
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | |
z-index: 1000; | |
max-width: 250px; | |
} | |
.global-controls h3 { | |
margin-bottom: var(--size-sm); | |
font-size: 0.875rem; | |
font-weight: var(--weight-semibold); | |
color: var(--clr-neutral-700); | |
} | |
.global-controls .control-group { | |
margin-bottom: var(--size-sm); | |
} | |
.global-controls label { | |
display: block; | |
font-size: 0.75rem; | |
margin-bottom: 2px; | |
color: var(--clr-neutral-600); | |
} | |
.global-controls select { | |
width: 100%; | |
padding: 4px; | |
border: 1px solid var(--clr-neutral-300); | |
border-radius: 3px; | |
font-size: 0.75rem; | |
} | |
/* Section Panel */ | |
.section-panel { | |
position: fixed; | |
top: 0; | |
left: 50%; | |
transform: translateX(-50%); | |
background: white; | |
border: 1px solid var(--clr-neutral-300); | |
border-top: none; | |
border-radius: 0 0 var(--size-sm) var(--size-sm); | |
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | |
z-index: 999; | |
transition: transform 0.3s ease; | |
min-width: 400px; | |
} | |
.section-panel.closed { | |
transform: translateX(-50%) translateY(-100%); | |
} | |
.panel-header { | |
padding: var(--size-sm) var(--size-md); | |
border-bottom: 1px solid var(--clr-neutral-200); | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
background: var(--clr-neutral-50); | |
} | |
.panel-header h3 { | |
font-size: 0.875rem; | |
font-weight: var(--weight-semibold); | |
} | |
.close-btn { | |
background: none; | |
border: none; | |
font-size: 1rem; | |
cursor: pointer; | |
padding: 2px 6px; | |
border-radius: 3px; | |
} | |
.close-btn:hover { | |
background: var(--clr-neutral-200); | |
} | |
.panel-tabs { | |
display: flex; | |
border-bottom: 1px solid var(--clr-neutral-200); | |
} | |
.tab-btn { | |
background: none; | |
border: none; | |
padding: var(--size-sm) var(--size-md); | |
cursor: pointer; | |
font-size: 0.75rem; | |
border-bottom: 2px solid transparent; | |
transition: all 0.2s; | |
} | |
.tab-btn:hover { | |
background: var(--clr-neutral-100); | |
} | |
.tab-btn.active { | |
border-bottom-color: var(--clr-blue-500); | |
background: var(--clr-blue-50); | |
} | |
.tab-content { | |
padding: var(--size-md); | |
display: none; | |
} | |
.tab-content.active { | |
display: block; | |
} | |
.control-grid { | |
display: grid; | |
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); | |
gap: var(--size-sm); | |
} | |
.control-item { | |
display: flex; | |
flex-direction: column; | |
} | |
.control-item label { | |
font-size: 0.75rem; | |
margin-bottom: 2px; | |
color: var(--clr-neutral-600); | |
} | |
.control-item select, | |
.control-item input { | |
padding: 4px 6px; | |
border: 1px solid var(--clr-neutral-300); | |
border-radius: 3px; | |
font-size: 0.75rem; | |
} | |
.color-swatch { | |
width: 20px; | |
height: 20px; | |
border-radius: 3px; | |
border: 1px solid var(--clr-neutral-300); | |
display: inline-block; | |
margin-right: 4px; | |
vertical-align: middle; | |
} | |
/* Main Content */ | |
.main-content { | |
padding: 6rem 2rem 2rem; | |
max-width: 1200px; | |
margin: 0 auto; | |
} | |
.content-section { | |
background: var(--clr-blue-200); | |
color: var(--clr-neutral-800); | |
padding: var(--size-lg); | |
margin-bottom: var(--size-lg); | |
border-radius: var(--size-sm); | |
border: 2px solid transparent; | |
font-family: var(--font-sans); | |
font-weight: var(--weight-normal); | |
position: relative; | |
cursor: pointer; | |
transition: all 0.2s; | |
} | |
.content-section:hover { | |
border-color: var(--clr-blue-400); | |
} | |
.content-section.selected { | |
border-color: var(--clr-blue-500); | |
box-shadow: 0 0 0 2px var(--clr-blue-200); | |
} | |
.section-label { | |
position: absolute; | |
top: -8px; | |
left: var(--size-sm); | |
background: var(--clr-blue-500); | |
color: white; | |
padding: 2px 6px; | |
border-radius: 3px; | |
font-size: 0.65rem; | |
font-weight: var(--weight-medium); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment