CSSV v1 · Examples · Experiments

Not what it's for. It works anyway.

CSSV is for tables: a CSV file with a CSS style block on top. Five of the six files on this page aren't tables in any useful sense: a film, a solar system, a skyline, a lava lamp and a music visualizer. The sixth is a league table that plays its whole season. Each is still an ordinary .cssv file rendered by <cssv-table>, with no other markup, and each leans on something the format already does.

Nobody should build a film this way. The point is that the pieces hold: field types work as selectors, typed attr() reads the key column as a number, sibling-index() gives every row a place, and update() patches a live table fast enough to animate.

01

A film in a spreadsheet

cinema.cssv

Every frame is a CSV file: 45 records of 80 fields, one field per pixel. The page hands each frame to the table with update(), 24 times a second. Each frame has the same columns and style block as the last, so the table isn't built again: its 3,600 cells stay, and only the fields that changed get a new class. A field's type is its shade, and the scene column is the key column, so tr[data-key="fire"] picks the palette and table:has(tr[data-key="horse"]) starts the iris. The last record is the subtitle.

  • data: 648 frames, drawn by the page
  • :empty / .negative / .zero / .positive
  • --cssv-key: scene
  • table:has(tr[data-key])
  • table.update()
td          { background: var(--p4); } /* x  */
td.positive { background: var(--p3); } /* 1  */
td.zero     { background: var(--p2); } /* 0  */
td.negative { background: var(--p1); } /* -1 */
td:empty    { background: var(--p0); } /* "" */

tr[data-key="fire"] { --p1: #5e0b00; … }
table:has(tr[data-key="horse"]) {
  animation: iris-in 1.6s ease-out;
}
27 seconds, 648 frames. Press play.

The frame on screen, as the text update() was given: lines 1 and 20 to 30 of 47, cut at 72 characters. In Plain CSV the horse is the gap where the fields are empty.


        

02

A solar system that runs itself

orrery.cssv

Eight records, one per planet, with NASA's figures. The key column is the orbital period in days, and typed attr() reads it as a number. That number becomes the orbit's animation-duration: one Earth year takes 8 seconds, so Mercury laps every 1.9 seconds and Neptune takes 22 minutes. The same number sets the orbit's size, because by Kepler's third law log() of the period is a logarithmic scale of distance. That's why the gap between Mars and Jupiter shows. The data sets the speeds and the sizes. No script runs: after the table renders, nothing calls update(). Each planet is a CSS animation along its orbit, and the orbits stay still, so a frame repaints only a few small boxes.

  • data: NASA Planetary Fact Sheet
  • --cssv-key: "orbit (days)"
  • attr(data-key type(<number>))
  • animation-duration
  • log()
  • offset-path
  • transform-style: preserve-3d
tbody tr {
  --period: attr(data-key type(<number>));
  --t: calc(var(--period) / 365.2 * var(--year, 8s));
  width: calc(2 * log(var(--period) / 40) * var(--unit));
}
td[data-col="planet"] {
  offset-path: circle(closest-side);
  animation: orbit var(--t) linear infinite;
}
@keyframes orbit {
  from { offset-distance: 100%; } to { offset-distance: 0%; }
}
The speed buttons set --year on the element. Custom properties inherit into the table.

03

A skyline that grows

skyline.cssv

The CSV of buildings is the city. Every record is one of the 43 buildings of 400 m or more on Wikipedia's list, tallest first, and every row is a tower. Its place in the file gives it a plot on a sunflower spiral, so the tallest stands in the middle. The height is the key column, and typed attr() turns it into the tower's height. The row's six cells are its faces: the name on the front, the city and the country on the sides, the floors at the back, the height on the roof and the year as its shadow. Play the years and the page hands the table the same file with every building not yet finished left without a height; each year, the new ones rise out of their plots.

  • data: Wikipedia, List of tallest buildings
  • attr(data-key type(<number>))
  • sibling-index() spiral
  • transform-style: preserve-3d
  • cells as faces
  • table.update()
tbody tr {                            /* a tower */
  --H: calc(attr(data-key type(<number>), 0) * var(--m));
  --a: calc(sibling-index() * 137.508deg);
  --r: calc(sqrt(sibling-index() - 1) * 4.6cqw);
  translate: calc(cos(var(--a)) * var(--r))
             calc(sin(var(--a)) * var(--r));
}
td[data-col="building"] {             /* its front wall */
  height: var(--H);
  transform: translateY(calc(var(--w) / 2)) rotateX(-90deg);
  transition: height 1.6s;
}
43 buildings of 400 m or more, as of 2026.

04

A lava lamp

lava.cssv

Thirteen records, one per blob of wax, and the key column is each blob's volume in millilitres. Typed attr() reads it and a cube root turns it into the blob's size. sibling-index() gives every blob its own path and pace, and a blur plus a contrast filter melts them into one liquid. Every three seconds the page moves a little wax from blob to blob, keeping the total, and hands the table the new volumes with update(). The rows stay, their sizes change with a transition, and the liquid flows into its new shape. The records are the wax.

  • data: 13 blobs of invented wax
  • --cssv-key: "wax (ml)"
  • attr(data-key type(<number>))
  • pow(…, 1 / 3)
  • sibling-index()
  • filter: blur() contrast()
  • table.update()
/* the glass: blur, then full contrast */
tbody { filter: blur(7px) contrast(22); }

/* a blob: its volume sets its size */
tbody tr {
  scale: calc(pow(attr(data-key type(<number>)), 1 / 3) / 2.9);
  transition: scale 2.6s ease-in-out;
  animation: rise calc((11 + mod(sibling-index() * 7, 9)) * 1s)
    ease-in-out infinite alternate;
}
13 blobs, 62.5 ml of wax.

05

A music visualizer

visualizer.cssv

A spectrum is a table: 32 frequency bands, one record each, with the band's center in Hz and its level from 0 to 100. Press play and the page plays a song with Web Audio, reads its spectrum and hands the table the next 32 records with update(), 15 times a second. The level is the key column, so typed attr() turns it into the length of each bar, and sibling-index() gives every band its angle and its color. A 0.13-second transition on scale fills the gaps between updates, so 15 updates a second move like 60 frames. The song is a CSV too: visualizer-song.csv, one record per note, played by oscillators.

  • data: 32 records, 15 times a second
  • --cssv-key: level
  • attr(data-key type(<number>))
  • sibling-index()
  • transition: scale
  • table.update()
tbody tr {
  rotate: calc((sibling-index() - 0.5)
    / sibling-count() * 1turn);
}
tbody tr::before { /* the bar */
  scale: 1 calc(0.04
    + attr(data-key type(<number>), 0) / 104);
  transition: scale 0.13s ease-out;
}
A 9.6-second loop in A minor, with sound. Press play.

06

A league table that plays its season

season.cssv

The one real table on this page: standings from a semicolon-delimited spreadsheet export, with the clubs in alphabetical order. Pos is the key column, so typed attr() reads each row's rank, and translate moves the row from its place in the file, which sibling-index() gives, to its rank. Each time the file loads, the rows slide from alphabetical order into the standings. Play the season and the page works out the standings after each matchday from the 90 results in season-results.csv and hands them to the table with update(). The rows stay the same elements, so their transition slides them to their new places, and z-index from the rank puts a club moving up over the club it overtakes. The lines and the zones stay where they are. The season ends at the gallery's League table.

  • data: an invented league, 90 results
  • ; delimiter
  • --cssv-key: Pos
  • attr(data-key type(<number>))
  • sibling-index()
  • transition: translate
  • table.update()
tbody tr {             /* from its place to its rank */
  translate: 0 calc((attr(data-key type(<number>), sibling-index())
    - sibling-index()) * var(--step));
  z-index: calc(100 - attr(data-key type(<integer>), 0));
  transition: translate 1s cubic-bezier(.65, 0, .35, 1);
}
18 matchdays, one every 1.6 seconds.