Integration Guide (server-rendered, no-JS first)Link to section
A guide for rendering Casca from any server-side stack. Casca's default and flagship use case is a static, no-JavaScript render: the server emits plain HTML once, you serve it as a static file, and the charts work with no runtime, no network round-trip, and no client JS. That path is covered first.
The Go-template + htmx patterns further down are an optional interactive enhancement (live refresh, fragment swaps), useful for an interactive app, but not required for a no-JS dashboard. Everything here is framework-agnostic; the examples happen to use Go templates, but the markup and the rules apply to Django, Phoenix, Rails, Laravel, PHP, or a raw handler in any language.
Read the server-render security contract before interpolating any dynamic value into Casca markup.
Quick StartLink to section
1. InstallationLink to section
Copy Casca CSS to your static assets:
# Option A: Copy built CSS
cp dist/casca-core.css static/css/
# Option B: Include in your CSS build pipeline
# Add src/casca-core.css to your build process
2. Include in TemplatesLink to section
// base.tmpl
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/static/css/casca-core.css">
</head>
<body>
{{template "content" .}}
</body>
</html>
3. Server-Side RenderingLink to section
Use the provided Go templates:
import "html/template"
func main() {
tmpl := template.Must(template.New("").Funcs(template.FuncMap{
"inc": func(i int) int { return i + 1 },
}).ParseGlob("templates/*.tmpl"))
// Include Casca templates
tmpl = template.Must(tmpl.ParseGlob("casca/go-templates/*.tmpl"))
http.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
data := getDashboardData()
tmpl.ExecuteTemplate(w, "dashboard", data)
})
}
Static, no-JS render (the default path)Link to section
The simplest and most robust way to ship Casca: render the HTML once and serve it as a static file. No JavaScript, no network round-trip, no template runtime at request time. This is the right recipe for public, auditable, print-and-screenshot dashboards.
Each chart is a <figure class="casca casca-figure"> containing two things:
- A
.casca-datatable, the accessible source of truth. Screen readers and a stylesheet-less reader get the real numbers from it. - The visual chart, marked
aria-hidden="true"(decorative; the table already carries the data). Usedata-labels="always"on a bar chart so the magnitudes are visible on the chart itself in print and on touch, with no hover.
<figure class="casca casca-figure">
<figcaption class="casca-title">Sessions by source</figcaption>
<!-- 1) accessible source of truth -->
<table class="casca-data">
<caption>Sessions by source</caption>
<tbody>
<tr><th scope="row">Organic search</th><td>4,200</td></tr>
<tr><th scope="row">Direct</th><td>3,100</td></tr>
</tbody>
</table>
<!-- 2) decorative visual; values shown without hover -->
<div class="casca-bar" data-orientation="horizontal" data-labels="always"
style="--casca-height: auto" aria-hidden="true">
<div class="casca-bar-group">
<div class="casca-bar-item">
<span class="casca-bar-label">Organic search</span>
<div class="casca-bar-value" style="--value: 100%"></div>
<span class="casca-bar-rank-value">4,200</span>
</div>
<!-- …one row per item… -->
</div>
</div>
</figure>
Render that to a file and serve it. No server is needed at view time:
# framework-agnostic pseudocode (the same shape in Go, Python, PHP, JS, …):
rows = query_or_load_data() # your data
max_value = max(r.value for r in rows)
html = render_template("dashboard.html", rows, max_value)
write_file("public/dashboard.html", html) # build step / cron / CI
# then: any static file server (nginx, CDN, `file://`) serves public/dashboard.html
The percentage each bar needs (--value) is computed by the server:
--value: {{ 100 * row.value / max_value }}%. The chart never does math in the
browser, so there is nothing to hydrate.
Pair this with ADOPTION.md ("when not to use Casca") if your data changes per-request and you actually want live updates. That is the optional htmx path below, not the static default.
Server-render security contractLink to section
Casca puts numbers into inline style custom properties
(style="--value: 60%", an angle, a count). A server that interpolates dynamic
or user-controlled values into that markup must follow these rules, or it risks
CSS / markup injection. This contract is the same one a strict static consumer
enforces and tests; follow it for any server-rendered output, JS or not.
1. Inline style carries server-COMPUTED NUMBERS only.
Only a value the server itself computes and formats as a number (the --value
percent, an --angle, a --qx, an OHLC level) may go into an inline style
custom property. Format it yourself (e.g. clamp to 0–100, append %); do not
pass a raw caller/user/stored string through.
# OK: server computed a number:
style="--value: {{ clampPercent(row.value, max) }}%"
# NEVER: a string from data/users in style (CSS/markup injection risk):
style="--value: {{ row.rawField }}" # ✗
style="--color: {{ user.themeString }}" # ✗ (could break out of the attribute)
2. Durable / untrusted STRINGS go in text or a quoted attribute, HTML-escaped.
Labels, categories, captions, titles (any string) belong in element text
content or a double-quoted attribute, and MUST be HTML-escaped (& < > " ')
at render time. Never place a string in style, href, src, or url().
# OK: escaped string in text content:
<span class="casca-bar-label">{{ escapeHTML(row.label) }}</span>
<span class="casca-bar-rank-value">{{ escapeHTML(row.formatted) }}</span>
3. attr(data-label) is fine, but still escape the value.
The hover/always value label uses data-label="…" rendered via CSS
content: attr(data-label) (CSS renders the attribute as text, so there is no CSS
injection through it). The attribute value is still a string in HTML, so escape it
like any other attribute:
data-label="{{ escapeHTML(row.formatted) }}"
Most server templating escapes HTML by default (Go html/template, Django
autoescape, etc.); the rule that needs care is #1. Those engines do not
make a raw string safe inside an inline style, so keep style numeric.
Dashboard PatternsLink to section
Cash Flow VisualizationLink to section
Requirement: Diverging baseline bars for income vs expenses
Implementation:
// models/cashflow.go
type CashFlowData struct {
ID string
Title string
Subtitle string
LabelColumn string
ShowGrid bool
PositiveLabel string
NegativeLabel string
DataPoints []CashFlowPoint
}
type CashFlowPoint struct {
Label string
Value float64
FormattedValue string
Percentage float64
}
// Helper: Calculate percentages relative to max absolute value
func PrepareCashFlowData(data []CashFlowPoint) []CashFlowPoint {
maxAbs := 0.0
for _, point := range data {
if abs := math.Abs(point.Value); abs > maxAbs {
maxAbs = abs
}
}
for i := range data {
data[i].Percentage = (math.Abs(data[i].Value) / maxAbs) * 100
}
return data
}
Template:
// templates/dashboard.tmpl
{{define "dashboard"}}
<section>
<h1>Cash Flow Dashboard</h1>
{{/* Use Casca's cash-flow template */}}
{{template "cash-flow" .CashFlowData}}
</section>
{{end}}
Controller:
func DashboardHandler(w http.ResponseWriter, r *http.Request) {
// Fetch from database
rawData := fetchMonthlyCashFlow()
// Prepare for charting
points := make([]CashFlowPoint, len(rawData))
for i, d := range rawData {
points[i] = CashFlowPoint{
Label: d.Month,
Value: d.NetFlow,
FormattedValue: formatCurrency(d.NetFlow),
}
}
data := CashFlowData{
ID: "monthly-cashflow",
Title: "Monthly Cash Flow",
Subtitle: "Income vs Expenses",
LabelColumn: "Month",
ShowGrid: true,
PositiveLabel: "Income",
NegativeLabel: "Expenses",
DataPoints: PrepareCashFlowData(points),
}
tmpl.ExecuteTemplate(w, "dashboard", map[string]interface{}{
"CashFlowData": data,
})
}
Multi-Series ComparisonLink to section
Requirement: Grouped bars for comparing metrics across time periods
Implementation:
// models/comparison.go
type GroupedBarData struct {
Title string
Subtitle string
CategoryColumn string
Categories []string
Series []SeriesInfo
DataByCategory [][]DataPoint
}
type SeriesInfo struct {
Name string
Color string
}
type DataPoint struct {
Value float64
FormattedValue string
Percentage float64
}
// Helper: Normalize percentages to 0-100 scale
func NormalizeToPercentage(values [][]float64) [][]DataPoint {
maxValue := 0.0
for _, series := range values {
for _, v := range series {
if v > maxValue {
maxValue = v
}
}
}
result := make([][]DataPoint, len(values))
for i, series := range values {
result[i] = make([]DataPoint, len(series))
for j, v := range series {
result[i][j] = DataPoint{
Value: v,
FormattedValue: formatCurrency(v),
Percentage: (v / maxValue) * 100,
}
}
}
return result
}
Controller:
func SalesComparisonHandler(w http.ResponseWriter, r *http.Request) {
rangeParam := r.URL.Query().Get("range") // "month", "quarter", "year"
// Fetch data for each year
data2023 := fetchSalesData(2023, rangeParam)
data2024 := fetchSalesData(2024, rangeParam)
data2025 := fetchSalesData(2025, rangeParam)
chartData := GroupedBarData{
Title: "Sales Comparison",
Subtitle: fmt.Sprintf("By %s", rangeParam),
CategoryColumn: strings.Title(rangeParam),
Categories: getCategories(rangeParam), // ["Q1", "Q2", "Q3", "Q4"]
Series: []SeriesInfo{
{Name: "2023", Color: "var(--casca-color-1)"},
{Name: "2024", Color: "var(--casca-color-2)"},
{Name: "2025", Color: "var(--casca-color-3)"},
},
DataByCategory: NormalizeToPercentage([][]float64{
data2023,
data2024,
data2025,
}),
}
tmpl.ExecuteTemplate(w, "grouped-bars", chartData)
}
Optional: live updates with htmxLink to section
This is the interactive opt-in, not the baseline. A no-JS / static dashboard (see Static, no-JS render) needs none of this. Reach for htmx only when the data must update without a full page load. The security contract applies to every fragment you render here too.
Requirement: Fragment swaps for dynamic chart updates without page reload
Pattern 1: Auto-Refreshing ChartsLink to section
<!-- Dashboard template -->
<section id="live-metrics"
hx-get="/api/charts/metrics"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
{{/* Initial chart loaded server-side */}}
{{template "cash-flow" .InitialData}}
</section>
Endpoint:
func MetricsChartHandler(w http.ResponseWriter, r *http.Request) {
// Fetch latest data
data := getCurrentMetrics()
// Return only the chart fragment (not full page)
tmpl.ExecuteTemplate(w, "cash-flow", data)
}
Pattern 2: User-Triggered UpdatesLink to section
<!-- Date range selector -->
<select name="range"
hx-get="/api/charts/sales"
hx-target="#sales-chart"
hx-swap="innerHTML">
<option value="month">Last Month</option>
<option value="quarter">Last Quarter</option>
<option value="year" selected>Last Year</option>
</select>
<div id="sales-chart">
{{template "grouped-bars" .SalesData}}
</div>
Endpoint:
func SalesChartHandler(w http.ResponseWriter, r *http.Request) {
rangeParam := r.URL.Query().Get("range")
data := getSalesData(rangeParam)
// Return chart fragment
tmpl.ExecuteTemplate(w, "grouped-bars", data)
}
Pattern 3: Interactive Chart ButtonsLink to section
<!-- Chart as submit button -->
<form hx-post="/api/actions/generate-report"
hx-target="#report-status"
hx-swap="innerHTML">
<button type="submit" class="casca casca-figure casca-interactive">
<div class="casca-title">Generate Report</div>
<div class="casca-gauge" style="--value: {{.Progress}}; --color: var(--casca-color-1)">
<div class="casca-gauge-value">
{{.Progress}}%
<span class="casca-gauge-label">Ready</span>
</div>
</div>
</button>
</form>
<div id="report-status"></div>
Handler:
func GenerateReportHandler(w http.ResponseWriter, r *http.Request) {
// Process report generation
reportID := generateReport()
// Return status chip
w.Write([]byte(fmt.Sprintf(`
<div class="casca-chip" data-variant="success">
✓ Report #%s generated
</div>
`, reportID)))
}
Paginated Legends for Large DatasetsLink to section
Requirement: Handle 10+ categories without overwhelming UI
Implementation:
// Utility function
func PaginateLegend(items []LegendItem, perPage int) []LegendPage {
var pages []LegendPage
for i := 0; i < len(items); i += perPage {
end := i + perPage
if end > len(items) {
end = len(items)
}
pages = append(pages, LegendPage{
Items: items[i:end],
})
}
return pages
}
Usage:
func CategoryBreakdownHandler(w http.ResponseWriter, r *http.Request) {
// Fetch all categories
categories := fetchAllCategories() // 20+ items
legendItems := make([]LegendItem, len(categories))
for i, cat := range categories {
legendItems[i] = LegendItem{
Label: cat.Name,
Value: fmt.Sprintf("%.1f%%", cat.Percentage),
Color: getColor(i),
}
}
data := PaginatedLegendData{
ID: "category-legend",
Pages: PaginateLegend(legendItems, 5), // 5 per page
}
tmpl.ExecuteTemplate(w, "paginated-legend", data)
}
Best PracticesLink to section
1. Data PreparationLink to section
Always normalize values to percentages:
// Good: Consistent 0-100% scale
func NormalizeValues(values []float64) []float64 {
max := math.Max(values...)
normalized := make([]float64, len(values))
for i, v := range values {
normalized[i] = (v / max) * 100
}
return normalized
}
// Bad: Raw values may exceed 100%
func UseRawValues(values []float64) []float64 {
return values // ❌ May break visual scale
}
2. AccessibilityLink to section
Always include data tables:
{{define "accessible-chart"}}
<figure class="casca casca-figure" role="img" aria-labelledby="chart-{{.ID}}-title">
<figcaption>
<div id="chart-{{.ID}}-title" class="casca-title">{{.Title}}</div>
</figcaption>
{{/* Data table for screen readers */}}
<table class="casca-data">
<caption>{{.Title}}</caption>
<thead>
<tr>
<th>{{.LabelColumn}}</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{{range .DataPoints}}
<tr>
<td>{{.Label}}</td>
<td>{{.FormattedValue}}</td>
</tr>
{{end}}
</tbody>
</table>
{{/* Visual chart */}}
<div class="casca-bar" aria-hidden="true">
<!-- Chart visualization -->
</div>
</figure>
{{end}}
3. PerformanceLink to section
Cache chart data:
type ChartCache struct {
mu sync.RWMutex
data map[string]interface{}
expiry map[string]time.Time
}
func (c *ChartCache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if exp, ok := c.expiry[key]; ok && time.Now().Before(exp) {
return c.data[key], true
}
return nil, false
}
func (c *ChartCache) Set(key string, data interface{}, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = data
c.expiry[key] = time.Now().Add(ttl)
}
// Usage
var chartCache = &ChartCache{
data: make(map[string]interface{}),
expiry: make(map[string]time.Time),
}
func CashFlowHandler(w http.ResponseWriter, r *http.Request) {
cacheKey := "cashflow:monthly"
if cached, ok := chartCache.Get(cacheKey); ok {
tmpl.ExecuteTemplate(w, "cash-flow", cached)
return
}
data := fetchAndPrepareCashFlow()
chartCache.Set(cacheKey, data, 5*time.Minute)
tmpl.ExecuteTemplate(w, "cash-flow", data)
}
4. Error HandlingLink to section
Graceful degradation:
func SafeChartRender(w http.ResponseWriter, data interface{}) {
if err := tmpl.ExecuteTemplate(w, "chart", data); err != nil {
// Log error
log.Printf("Chart render error: %v", err)
// Return accessible fallback
fmt.Fprintf(w, `
<div class="casca casca-figure" role="alert">
<div class="casca-chip" data-variant="error">
⚠ Chart data temporarily unavailable
</div>
<table class="casca-data">
<!-- Raw data table as fallback -->
</table>
</div>
`)
}
}
TestingLink to section
Unit TestsLink to section
func TestCashFlowDataPreparation(t *testing.T) {
input := []CashFlowPoint{
{Value: 15000},
{Value: -8000},
{Value: 22000},
}
result := PrepareCashFlowData(input)
// Max absolute value is 22000
// 15000 / 22000 * 100 ≈ 68.18%
assert.InDelta(t, 68.18, result[0].Percentage, 0.01)
// -8000 / 22000 * 100 ≈ 36.36%
assert.InDelta(t, 36.36, result[1].Percentage, 0.01)
// 22000 / 22000 * 100 = 100%
assert.Equal(t, 100.0, result[2].Percentage)
}
Integration TestsLink to section
func TestHTMXChartSwap(t *testing.T) {
// Create test server
ts := httptest.NewServer(http.HandlerFunc(MetricsChartHandler))
defer ts.Close()
// Make htmx request
req, _ := http.NewRequest("GET", ts.URL+"/api/charts/metrics", nil)
req.Header.Set("HX-Request", "true")
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
// Verify response contains chart HTML
body, _ := ioutil.ReadAll(resp.Body)
assert.Contains(t, string(body), `class="casca-bar"`)
assert.Contains(t, string(body), `data-diverging`)
}
Deployment ChecklistLink to section
- Copy
casca-core.cssto static assets - Include Casca CSS in base template
- Copy Go template files to templates directory
- Register template functions (
inc, etc.) - Test all chart types render correctly
- Verify htmx fragment swaps work
- Test with JavaScript disabled (NO-JS baseline)
- Run accessibility audit (WAVE, axe DevTools)
- Test on mobile devices
- Verify dark mode support
- Load test chart endpoints under high traffic
- Set up caching for frequently accessed charts
- Monitor bundle size (should be ~38KB)
Example Project StructureLink to section
myapp/
├── static/
│ └── css/
│ └── casca-core.css
├── templates/
│ ├── base.tmpl
│ ├── dashboard.tmpl
│ └── casca/
│ ├── cash-flow.tmpl
│ ├── grouped-bars.tmpl
│ ├── paginated-legend.tmpl
│ └── htmx-integration.tmpl
├── handlers/
│ ├── dashboard.go
│ ├── charts.go
│ └── api.go
├── models/
│ ├── cashflow.go
│ └── metrics.go
└── main.go
SupportLink to section
For integration questions:
- Review examples in
site/assets/integrations/go-templates/ - Check API reference in
docs/API.md - See accessibility guide in
docs/ACCESSIBILITY.md
For bugs or feature requests:
- Open issue with the
integrationtag - Include Go/htmx (or your stack) version info
- Provide minimal reproduction example