diff --git a/docs/blog/septa-timetables/chw-stops.csv b/docs/blog/septa-timetables/chw-stops.csv
new file mode 100644
index 000000000..d9fedd5d9
--- /dev/null
+++ b/docs/blog/septa-timetables/chw-stops.csv
@@ -0,0 +1,15 @@
+service_access,service_cash,service_park,fare_zone,stop_id,stop_name,stop_desc,stop_lat,stop_lon,zone_id,stop_url
+1,0,1,2,90004,Gray 30th Street,,39.9566667,-75.1816667,CC,
+0,0,1,2,90005,Suburban Station,,39.9538889,-75.1677778,CC,
+0,0,1,2,90006,Jefferson Station,,39.9525,-75.1580556,CC,
+1,0,1,2,90007,Temple University,,39.9813889,-75.1494444,CC,
+0,0,1,2,90801,Chestnut Hill West,,40.0763889,-75.2083333,2S,
+0,0,1,2,90802,Highland,,40.0705556,-75.2111111,2S,
+0,0,1,1,90803,St. Martins,,40.0658333,-75.2044444,2S,
+0,0,1,1,90804,Richard Allen Lane,,40.0575,-75.1947222,2S,
+1,0,1,1,90805,Carpenter,,40.0511111,-75.1913889,2S,
+0,0,0,1,90806,Upsal,,40.0425,-75.19,2S,
+1,1,0,C,90807,Tulpehocken,,40.0352778,-75.1869444,2S,
+1,1,0,C,90808,Chelten Avenue,,40.03,-75.1808333,1S,
+1,1,0,C,90809,Queen Lane,,40.0233333,-75.1780556,1S,
+1,0,0,C,90810,North Philadelphia,,39.9977778,-75.1563889,1S,
diff --git a/docs/blog/septa-timetables/example-timetable.png b/docs/blog/septa-timetables/example-timetable.png
new file mode 100644
index 000000000..196ee7bb6
Binary files /dev/null and b/docs/blog/septa-timetables/example-timetable.png differ
diff --git a/docs/blog/septa-timetables/index.qmd b/docs/blog/septa-timetables/index.qmd
new file mode 100644
index 000000000..d3b71268a
--- /dev/null
+++ b/docs/blog/septa-timetables/index.qmd
@@ -0,0 +1,253 @@
+---
+title: Recreating Septa Transit Timetables in Python
+format: html
+html-table-processing: none
+jupyter: python3
+---
+
+Recently, Rich and I were poking around transit data, and we were struck by the amount of structuring that goes into transit timetables.
+
+For example, consider this weekend rail schedule table from SEPTA, Philadelphia's transit agency.
+
+{style="max-width: 700px; display: block; margin-left: auto; margin-right: auto;"}
+
+
+Notice these big pieces:
+
+* The vertical text on the left indicating trains are traveling "TO CENTER CITY".
+* The blue header, and spanner columns ("Services" and "Train Number") grouping related columns.
+* The striped background for easier reading. Also the black background indicating stations in the Center City (the urban core).
+
+Tables like this often have to be created in illustrator, and updated by hand. At the same time, when agencies automate table creation, they often sacrifice a lot of the assistive features and helpful affordances of the table.
+
+We set out to recreate this table in Great Tables (and by we I mean 99% Rich).
+
+Here's a look at the final table in Great Tables.
+
+```{python}
+# | code-fold: true
+from great_tables import GT, html, style, loc, google_font
+import polars as pl
+import polars.selectors as cs
+
+stops = pl.read_csv("chw-stops.csv")
+times = pl.read_csv("times.csv")
+
+stop_times = times.join(other=stops, on="stop_name", maintain_order="left").select(
+ pl.lit("To Center City").alias("direction"), pl.col("*")
+)
+
+
+def h_m_p(s):
+ h, m, _ = [int(part) for part in s.split(":")]
+ ap = "a"
+
+ if h > 12:
+ h -= 12
+ ap = "p"
+ return f"{h}:{m:02d}{ap}"
+
+
+def tick(b):
+ return "✓" if b else ""
+
+
+transit_table = (
+ GT(stop_times)
+ .tab_stub(groupname_col="direction")
+ .tab_header("Saturdays, Sundays, and Major Holidays")
+ .cols_hide(
+ columns=["stop_url", "zone_id", "stop_desc", "stop_lat", "stop_lon", "stop_id"]
+ )
+ .fmt(h_m_p, columns=cs.matches(r"^[0-9]{4}$"))
+ .fmt(tick, columns=cs.starts_with("service_"))
+ .cols_label(
+ stop_name="Stations",
+ service_access="A",
+ service_cash="C",
+ service_park="P",
+ fare_zone=html("Fare
Zone"),
+ )
+ .tab_spanner(label="Services", columns=cs.starts_with("service_"))
+ .tab_spanner(label="Train Number", columns=cs.matches(r"^[0-9]{4}$"))
+ .cols_move_to_start("fare_zone")
+ .cols_move_to_start(cs.starts_with("service_"))
+ .cols_width(
+ cases={c: "20px" for c in stop_times.columns if c.startswith("service_")}
+ )
+ .cols_width(cases={c: "60px" for c in stop_times.columns if c.startswith("8")})
+ .opt_row_striping(row_striping=True)
+ .cols_align(align="center", columns="fare_zone")
+ .cols_align(align="right", columns=cs.matches(r"^[0-9]{4}$"))
+ # style header
+ .tab_style(
+ locations=loc.header(),
+ style=style.css(
+ "background-color: rgb(66, 99, 128) !important; color: white !important; font-size: 24px !important; font-weight: bold !important; border-width: 0px !important;",
+ ),
+ )
+ # style vertical text on left
+ .tab_style(
+ locations=loc.row_groups(),
+ # TODO: rotate text vertically
+ style=style.css(
+ "writing-mode: sideways-lr; padding-bottom: 25% !important; font-size: 24px !important; font-weight: bold !important; text-transform: uppercase !important;"
+ ),
+ )
+ .tab_style(
+ style=style.css(
+ "background-color: black !important; color: white !important; border-top: none !important; border-bottom: none !important;"
+ ),
+ locations=loc.body(columns=None, rows=list(range(-4, -1))),
+ )
+ .tab_style(
+ style=style.css(
+ """
+ border-top: none !important;
+ border-bottom: none !important;
+ border-right: solid white 2px !important;
+ color: white !important;
+ """
+ ),
+ locations=loc.body(
+ columns=~cs.matches(r"^[0-9]{4}$"), rows=list(range(-4, -1))
+ ),
+ )
+ .tab_style(
+ style=style.css("border-right: solid black 2px !important;"),
+ locations=loc.body(
+ columns=~cs.matches(r"^[0-9]{4}$"), rows=list(range(0, 10)) + [13]
+ ),
+ )
+ .tab_options(
+ row_striping_background_color="#A9A9A9",
+ row_group_as_column=True,
+ )
+ .opt_table_outline()
+ .opt_table_font(font=google_font("IBM Plex Sans"))
+)
+
+transit_table
+```
+
+## Reading in stops and times
+
+```{python}
+import polars as pl
+
+stops = pl.read_csv("chw-stops.csv")
+times = pl.read_csv("times.csv")
+```
+
+## Joining to get stop times
+
+```{python}
+stop_times = times.join(other=stops, on="stop_name", maintain_order="left").select(
+ pl.lit("To Center City").alias("direction"), pl.col("*")
+)
+
+
+stop_times
+```
+
+## Creating table
+
+```{python}
+from great_tables import GT, html, style, loc, google_font
+import polars as pl
+import polars.selectors as cs
+
+
+def h_m_p(s):
+ h, m, _ = [int(part) for part in s.split(":")]
+ ap = "a"
+
+ if h > 12:
+ h -= 12
+ ap = "p"
+ return f"{h}:{m:02d}{ap}"
+
+
+def tick(b):
+ return "✓" if b else ""
+
+
+transit_table = (
+ GT(stop_times)
+ .tab_stub(groupname_col="direction")
+ .tab_header("Saturdays, Sundays, and Major Holidays")
+ .cols_hide(
+ columns=["stop_url", "zone_id", "stop_desc", "stop_lat", "stop_lon", "stop_id"]
+ )
+ .fmt(h_m_p, columns=cs.matches(r"^[0-9]{4}$"))
+ .fmt(tick, columns=cs.starts_with("service_"))
+ .cols_label(
+ stop_name="Stations",
+ service_access="A",
+ service_cash="C",
+ service_park="P",
+ fare_zone=html("Fare
Zone"),
+ )
+ .tab_spanner(label="Services", columns=cs.starts_with("service_"))
+ .tab_spanner(label="Train Number", columns=cs.matches(r"^[0-9]{4}$"))
+ .cols_move_to_start("fare_zone")
+ .cols_move_to_start(cs.starts_with("service_"))
+ .cols_width(
+ cases={c: "18px" for c in stop_times.columns if c.startswith("service_")}
+ )
+ .cols_width(cases={c: "60px" for c in stop_times.columns if c.startswith("8")})
+ .opt_row_striping(row_striping=True)
+ .cols_align(align="center", columns="fare_zone")
+ .cols_align(align="right", columns=cs.matches(r"^[0-9]{4}$"))
+ # style header
+ .tab_style(
+ locations=loc.header(),
+ style=style.css(
+ "background-color: rgb(66, 99, 128) !important; color: white !important; font-size: 24px !important; font-weight: bold !important; border-width: 0px !important;",
+ ),
+ )
+ # style vertical text on left
+ .tab_style(
+ locations=loc.row_groups(),
+ # TODO: rotate text vertically
+ style=style.css(
+ "writing-mode: sideways-lr; padding-bottom: 25% !important; font-size: 24px !important; font-weight: bold !important; text-transform: uppercase !important;"
+ ),
+ )
+ .tab_style(
+ style=style.css(
+ "background-color: black !important; color: white !important; border-top: none !important; border-bottom: none !important;"
+ ),
+ locations=loc.body(columns=None, rows=list(range(-4, -1))),
+ )
+ .tab_style(
+ style=style.css(
+ """
+ border-top: none !important;
+ border-bottom: none !important;
+ border-right: solid white 2px !important;
+ color: white !important;
+ """
+ ),
+ locations=loc.body(
+ columns=~cs.matches(r"^[0-9]{4}$"), rows=list(range(-4, -1))
+ ),
+ )
+ .tab_style(
+ style=style.css("border-right: solid black 2px !important;"),
+ locations=loc.body(
+ columns=~cs.matches(r"^[0-9]{4}$"), rows=list(range(0, 10)) + [13]
+ ),
+ )
+ .tab_options(
+ row_striping_background_color="#A9A9A9",
+ row_group_as_column=True,
+ )
+ .opt_table_outline()
+ .opt_table_font(font=google_font("IBM Plex Sans"))
+)
+
+transit_table
+```
+
+
diff --git a/docs/blog/septa-timetables/stops-times.csv b/docs/blog/septa-timetables/stops-times.csv
new file mode 100644
index 000000000..2eb1c8728
--- /dev/null
+++ b/docs/blog/septa-timetables/stops-times.csv
@@ -0,0 +1,15 @@
+service_access,service_cash,service_park,fare_zone,stop_id,stop_name,stop_desc,stop_lat,stop_lon,zone_id,stop_url,8210,8242,8318,8322,8338,8716,8750,8756
+1,0,1,2,90004,Gray 30th Street,,39.9566667,-75.1816667,CC,,07:23:00,15:23:00,09:23:00,10:23:00,14:26:00,08:42:00,17:20:00,18:54:00
+0,0,1,2,90005,Suburban Station,,39.9538889,-75.1677778,CC,,07:28:00,15:28:00,09:28:00,10:28:00,14:31:00,08:47:00,17:25:00,18:59:00
+0,0,1,2,90006,Jefferson Station,,39.9525,-75.1580556,CC,,07:33:00,15:33:00,09:33:00,10:33:00,14:36:00,08:52:00,17:30:00,19:04:00
+1,0,1,2,90007,Temple University,,39.9813889,-75.1494444,CC,,07:37:00,15:37:00,09:37:00,10:37:00,14:40:00,08:57:00,17:35:00,19:08:00
+0,0,1,2,90801,Chestnut Hill West,,40.0763889,-75.2083333,2S,,06:51:00,14:49:00,08:49:00,09:49:00,13:52:00,08:08:00,16:48:00,18:20:00
+0,0,1,2,90802,Highland,,40.0705556,-75.2111111,2S,,06:52:00,14:50:00,08:50:00,09:50:00,13:53:00,08:09:00,16:49:00,18:21:00
+0,0,1,1,90803,St. Martins,,40.0658333,-75.2044444,2S,,06:54:00,14:52:00,08:52:00,09:52:00,13:55:00,08:11:00,16:51:00,18:23:00
+0,0,1,1,90804,Richard Allen Lane,,40.0575,-75.1947222,2S,,06:56:00,14:54:00,08:54:00,09:54:00,13:57:00,08:13:00,16:53:00,18:25:00
+1,0,1,1,90805,Carpenter,,40.0511111,-75.1913889,2S,,06:58:00,14:56:00,08:56:00,09:56:00,13:59:00,08:15:00,16:55:00,18:27:00
+0,0,0,1,90806,Upsal,,40.0425,-75.19,2S,,07:00:00,14:58:00,08:58:00,09:58:00,14:01:00,08:17:00,16:57:00,18:29:00
+1,1,0,C,90807,Tulpehocken,,40.0352778,-75.1869444,2S,,07:02:00,15:00:00,09:00:00,10:00:00,14:03:00,08:19:00,16:59:00,18:31:00
+1,1,0,C,90808,Chelten Avenue,,40.03,-75.1808333,1S,,07:04:00,15:02:00,09:02:00,10:02:00,14:05:00,08:21:00,17:01:00,18:33:00
+1,1,0,C,90809,Queen Lane,,40.0233333,-75.1780556,1S,,07:06:00,15:04:00,09:04:00,10:04:00,14:07:00,08:23:00,17:03:00,18:35:00
+1,0,0,C,90810,North Philadelphia,,39.9977778,-75.1563889,1S,,07:12:00,15:12:00,09:12:00,10:12:00,14:15:00,08:29:00,17:09:00,18:41:00
diff --git a/docs/blog/septa-timetables/times.csv b/docs/blog/septa-timetables/times.csv
new file mode 100644
index 000000000..d2029b30c
--- /dev/null
+++ b/docs/blog/septa-timetables/times.csv
@@ -0,0 +1,15 @@
+stop_name,8210,8242,8318,8322,8338,8716,8750,8756
+Chestnut Hill West,06:51:00,14:49:00,08:49:00,09:49:00,13:52:00,08:08:00,16:48:00,18:20:00
+Highland,06:52:00,14:50:00,08:50:00,09:50:00,13:53:00,08:09:00,16:49:00,18:21:00
+St. Martins,06:54:00,14:52:00,08:52:00,09:52:00,13:55:00,08:11:00,16:51:00,18:23:00
+Richard Allen Lane,06:56:00,14:54:00,08:54:00,09:54:00,13:57:00,08:13:00,16:53:00,18:25:00
+Carpenter,06:58:00,14:56:00,08:56:00,09:56:00,13:59:00,08:15:00,16:55:00,18:27:00
+Upsal,07:00:00,14:58:00,08:58:00,09:58:00,14:01:00,08:17:00,16:57:00,18:29:00
+Tulpehocken,07:02:00,15:00:00,09:00:00,10:00:00,14:03:00,08:19:00,16:59:00,18:31:00
+Chelten Avenue,07:04:00,15:02:00,09:02:00,10:02:00,14:05:00,08:21:00,17:01:00,18:33:00
+Queen Lane,07:06:00,15:04:00,09:04:00,10:04:00,14:07:00,08:23:00,17:03:00,18:35:00
+North Philadelphia,07:12:00,15:12:00,09:12:00,10:12:00,14:15:00,08:29:00,17:09:00,18:41:00
+Gray 30th Street,07:23:00,15:23:00,09:23:00,10:23:00,14:26:00,08:42:00,17:20:00,18:54:00
+Suburban Station,07:28:00,15:28:00,09:28:00,10:28:00,14:31:00,08:47:00,17:25:00,18:59:00
+Jefferson Station,07:33:00,15:33:00,09:33:00,10:33:00,14:36:00,08:52:00,17:30:00,19:04:00
+Temple University,07:37:00,15:37:00,09:37:00,10:37:00,14:40:00,08:57:00,17:35:00,19:08:00