diff --git a/taccsite_cms/static/site_cms/css/modules/sortableTable.css b/taccsite_cms/static/site_cms/css/modules/sortableTable.css
new file mode 100644
index 000000000..890b93f90
--- /dev/null
+++ b/taccsite_cms/static/site_cms/css/modules/sortableTable.css
@@ -0,0 +1,44 @@
+/**
+ * Styles for CMS tables with class `is-sortable`
+ * IMPORTANT: Coupled with sortableTable.js
+ */
+
+table.is-sortable {
+ /* Buttons */
+ & .is-sortable__sort {
+ width: 100%; /* so entire cell is clickable */
+
+ /* To improve legibility during visible focus */
+ &:focus-visible {
+ outline-offset: 0.25em;
+ }
+
+ /* So known button styles have no ill effect */
+ &.btn-link,
+ &.c-button--as-link {
+ line-height: inherit;
+ height: 1lh;
+ justify-content: start;
+ vertical-align: baseline;
+ }
+ &.c-button--as-link {
+ height: 1lh;
+ }
+ &.btn-link {
+ padding: 0;
+ }
+ }
+
+ /* Sort indicators */
+ & th[aria-sort="ascending"] .is-sortable__sort::after { content: '▲'; }
+ & th[aria-sort="descending"] .is-sortable__sort::after { content: '▼'; }
+ & th[aria-sort="ascending"] .is-sortable__sort,
+ & th[aria-sort="descending"] .is-sortable__sort {
+ &::after {
+ font-size: smaller;
+ }
+ &:not(.c-button--as-link)::after {
+ margin-inline-start: 0.35em;
+ }
+ }
+}
diff --git a/taccsite_cms/static/site_cms/js/modules/sortableTable.js b/taccsite_cms/static/site_cms/js/modules/sortableTable.js
new file mode 100644
index 000000000..e1a5d01af
--- /dev/null
+++ b/taccsite_cms/static/site_cms/js/modules/sortableTable.js
@@ -0,0 +1,188 @@
+/**
+ * Client-side sort for CMS tables with class `is-sortable`
+ * IMPORTANT: Coupled with `sortableTable.css`
+ *
+ * TODO: Consider adding to TACC/Core-Components
+ * NOTE: Not using tristen/tablesort cuz it forgoes button in header cell (a11y)
+ * SEE: https://github.com/tristen/tablesort
+ * SEE: https://www.w3.org/WAI/ARIA/apg/patterns/table/examples/sortable-table/
+ *
+ * Editor markup:
+ * - Table: `o-fixed-header-table is-sortable`
+ * - Non-sortable column: `th.is-not-sortable` (e.g. Description)
+ */
+
+const SORT_TABLE_CLASS = 'is-sortable';
+const NOT_SORTABLE_CLASS = 'is-not-sortable';
+const SORT_BUTTON_CLASS = 'is-sortable__sort';
+
+const DEFAULT_TABLE_SELECTOR = 'table.' + SORT_TABLE_CLASS;
+const NOT_SORTABLE_SELECTOR = 'th.' + NOT_SORTABLE_CLASS;
+
+/**
+ * @param {HTMLTableCellElement | undefined} cell
+ * @param {{ table: HTMLTableElement, warnedMissingCell?: boolean }} [logContext]
+ * @returns {string}
+ */
+function getSortValue(cell, logContext) {
+ if (!cell) {
+ if (logContext && !logContext.warnedMissingCell) {
+ logContext.warnedMissingCell = true;
+ console.warn(
+ '[sortableTable] A row is missing a cell for the sorted column. Use the same number of columns on every row in the CMS table (watch colspan/rowspan).',
+ logContext.table
+ );
+ }
+ return '';
+ }
+
+ const link = cell.querySelector('a');
+ const text = link ? link.textContent : cell.textContent;
+ return (text ?? '').trim();
+}
+
+/**
+ * @param {HTMLTableElement} table
+ * @param {HTMLTableRowElement} headerRow
+ */
+function warnIfIrregularRows(table, headerRow) {
+ const tbody = table.tBodies[0];
+ if (!tbody) {
+ return;
+ }
+
+ const expected = headerRow.cells.length;
+
+ for (let i = 0; i < tbody.rows.length; i++) {
+ const row = tbody.rows[i];
+ if (row.cells.length < expected) {
+ console.warn(
+ `[sortableTable] Row ${i + 1} has ${row.cells.length} cells but the header has ${expected}. Fix table markup in the CMS before publishing.`,
+ table
+ );
+ return;
+ }
+ }
+}
+
+/**
+ * @param {HTMLTableElement} table
+ * @param {number} columnIndex
+ * @param {'ascending' | 'descending'} direction
+ */
+function sortTable(table, columnIndex, direction) {
+ const tbody = table.tBodies[0];
+ if (!tbody) {
+ return;
+ }
+
+ const rows = [ ...tbody.rows ];
+ const multiplier = direction === 'ascending' ? 1 : -1;
+ const logContext = { table, warnedMissingCell: false };
+
+ rows.sort((rowA, rowB) => {
+ const a = getSortValue(rowA.cells[columnIndex], logContext);
+ const b = getSortValue(rowB.cells[columnIndex], logContext);
+ return multiplier * a.localeCompare(b, undefined, { sensitivity: 'base' });
+ });
+
+ rows.forEach((row) => tbody.appendChild(row));
+}
+
+/**
+ * @param {HTMLTableCellElement} headerCell
+ * @param {'ascending' | 'descending' | 'none'} ariaSort
+ */
+function setHeaderSortState(headerCell, ariaSort) {
+ headerCell.setAttribute('aria-sort', ariaSort);
+ const button = headerCell.querySelector('button');
+ if (button) {
+ button.setAttribute(
+ 'aria-label',
+ ariaSort === 'none'
+ ? headerCell.dataset.sortLabel
+ : `${headerCell.dataset.sortLabel}, sorted ${ariaSort}`
+ );
+ }
+}
+
+/**
+ * @param {HTMLTableElement} table
+ * @param {string} notSortableSelector
+ * @param {string} buttonClass
+ */
+function initSortableTable(table, notSortableSelector, buttonClass) {
+ const headerRow = table.tHead?.rows[0];
+ if (!headerRow) {
+ return;
+ }
+
+ warnIfIrregularRows(table, headerRow);
+
+ /** @type {HTMLTableCellElement[]} */
+ const sortableHeaders = [];
+
+ [ ...headerRow.cells ].forEach((cell, index) => {
+ if (!(cell instanceof HTMLTableCellElement)) {
+ return;
+ }
+ if (cell.matches(notSortableSelector)) {
+ cell.classList.add(NOT_SORTABLE_CLASS);
+ return;
+ }
+
+ const label = cell.textContent?.trim() ?? `Column ${index + 1}`;
+ cell.dataset.sortLabel = label;
+ cell.innerHTML = '';
+
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = buttonClass + ' ' + SORT_BUTTON_CLASS;
+ button.textContent = label;
+ cell.append(button);
+
+ button.addEventListener('click', () => {
+ const current = cell.getAttribute('aria-sort');
+ const next =
+ current === 'ascending' ? 'descending' : 'ascending';
+
+ sortableHeaders.forEach((other) => {
+ if (other !== cell) {
+ setHeaderSortState(other, 'none');
+ }
+ });
+
+ setHeaderSortState(cell, next);
+ sortTable(table, index, next);
+ });
+
+ sortableHeaders.push(cell);
+ });
+
+ if (sortableHeaders.length) {
+ const first = sortableHeaders[0];
+ const firstIndex = [ ...headerRow.cells ].indexOf(first);
+ setHeaderSortState(first, 'ascending');
+ sortTable(table, firstIndex, 'ascending');
+ }
+}
+
+/**
+ * @param {object} [options]
+ * @param {ParentNode} [options.scopeElement=document]
+ * @param {string} [options.tableSelector=table.is-sortable]
+ * @param {string} [options.notSortableSelector=th.is-not-sortable]
+ * @param {string} [options.buttonClass=''] // e.g. 'c-button c-button--as-link'
+ */
+export default function sortableTable({
+ scopeElement = document,
+ tableSelector = DEFAULT_TABLE_SELECTOR,
+ notSortableSelector = NOT_SORTABLE_SELECTOR,
+ buttonClass = '',
+} = {}) {
+ scopeElement.querySelectorAll(tableSelector).forEach((table) => {
+ if (table instanceof HTMLTableElement) {
+ initSortableTable(table, notSortableSelector, buttonClass);
+ }
+ });
+}
diff --git a/taccsite_cms/templates/assets_core_delayed.html b/taccsite_cms/templates/assets_core_delayed.html
index e349ebabc..f50073914 100644
--- a/taccsite_cms/templates/assets_core_delayed.html
+++ b/taccsite_cms/templates/assets_core_delayed.html
@@ -8,8 +8,10 @@
{# TACC/Core-CMS #}
+