Lists & Conditionals

Rendering lists is just .map(). Conditionals are ternaries or &&. Re-render the container when data changes - mx updates efficiently.

let { ul, li, button, div } = mx;

let items = ["alpha", "beta"];

let host = dom.div();

function view(){
  return div(
    ul( ...items.map(x => li(x)) ),
    button({ onclick(){ items.push("item " + (items.length+1)); host.render(view()); } }, "Add"),
    button({ onclick(){ items.pop(); host.render(view()); } }, "Remove")
  );
}

host.render(view());
document.body.render(host);

Tip

Re-rendering a container is usually simpler than surgically mutating children. mx updates efficiently.

Card + Slots (composition pattern)

Pass children by name using a plain object. No Shadow DOM required. The Card function checks whether kids is an array or an object to support both default children and named slots.

let { div, h4, p, button } = mx;

let Card = (props = {}, kids = []) => {
  let slots = Array.isArray(kids) ? { default: kids } : kids;
  return div({ class:"card" },
    div({ class:"card__header" }, h4(props.title || "Card") ),
    div({ class:"card__body" }, ...(slots.default || []) ),
    div({ class:"card__footer" }, ...(slots.footer || []) )
  );
};

document.body.render(
  Card({ title:"Welcome" }, {
    default: [ p("Cards compose apps nicely.") ],
    footer: [ button("Close") ]
  })
);

Data Tables

A sortable table component. Sort state lives in $state so clicking column headers toggles sort direction and re-renders.

define('sortable-table', {
  $({ data = [], columns = [] }) {
    let { table, thead, tbody, tr, th, td, div } = mx;
    let sortKey = (this.$state.sortKey ??= columns[0]?.key);
    let asc = (this.$state.asc ??= true);

    let sorted = [...data].sort((a, b) => {
      let val = asc ? 1 : -1;
      return a[sortKey] > b[sortKey] ? val : -val;
    });

    return table(
      thead(tr(...columns.map(col =>
        th({ onclick: () => this.$({
          sortKey: col.key,
          asc: sortKey === col.key ? !asc : true
        })},
          col.label || col.key,
          sortKey === col.key ? (asc ? ' ↑' : ' ↓') : ''
        )
      ))),
      tbody(...sorted.map(u =>
        tr(...columns.map(col => td(u[col.key])))
      ))
    );
  }
});

let users = [
  { name: "Alice", age: 25, city: "NYC" },
  { name: "Bob", age: 30, city: "LA" },
  { name: "Charlie", age: 35, city: "Chicago" },
  { name: "Diana", age: 28, city: "Miami" },
  { name: "Ethan", age: 42, city: "Seattle" }
];

document.body.render(
  mx.sortableTable({
    columns: [
      { key: "name", label: "Name" },
      { key: "age", label: "Age" },
      { key: "city", label: "City" }
    ],
    data: users
  })
);

Virtual Scrolling

For large lists (thousands of items), render only visible rows. A virtual-container component keeps the DOM small by rendering only what's on screen:

define('virtual-container', {
  $({ height = 400, rowHeight = 48, rows = [] }) {
    this.$visibleRows = Math.floor((height - 1) / rowHeight);
    this.$lastOffset = 0;
    this.style.cssText = `display:block;height:${height}px;overflow-y:auto;overflow-x:hidden`;

    let renderVisible = offset => {
      let visible = rows.slice(offset, offset + this.$visibleRows + 1);
      return [
        mx.div({ style: `height:${offset * rowHeight}px` }),
        ...visible,
        mx.div({ style: `height:${(rows.length - offset - visible.length) * rowHeight}px` })
      ];
    };

    this.onscroll = e => {
      let scrollTop = Math.max(0, e.target.scrollTop);
      let offset = Math.floor(scrollTop / rowHeight);
      if (offset === this.$lastOffset) return;
      this.$lastOffset = offset;
      this.render(...renderVisible(offset));
      this.scrollTop = scrollTop;
    };

    return renderVisible(0);
  }
});

Use it with large datasets:

// Generate 10,000 items
let largeDataset = Array.from({ length: 10000 }, (_, i) =>
  mx.div({
    class: 'row',
    style: 'height: 48px; padding: 12px; border-bottom: 1px solid var(--border-color);'
  }, `Item ${i + 1}`)
);

document.body.render(
  mx.virtualContainer({
    height: 400,
    rowHeight: 48,
    rows: largeDataset
  })
);

How it works

Virtual scrolling creates spacer divs above and below the visible content to maintain scroll height, while only rendering ~10-20 visible items instead of all 10,000. This keeps the DOM small and performant.

Date Picker

A date picker component using $state to track the viewed month and selected date independently. Navigating months preserves the selection.

define('date-picker', {
  $({ selected, onSelect }) {
    let { div, button, table, thead, tbody, tr, th, td } = mx;

    let picked = (this.$state.picked ??= selected || new Date);
    let view = (this.$state.view ??= new Date(picked));

    let year = view.getFullYear();
    let month = view.getMonth();
    let daysInMonth = new Date(year, month + 1, 0).getDate();
    let firstDay = new Date(year, month, 1).getDay();

    let days = [];
    for (let i = 0; i < firstDay; i++) days.push("");
    for (let d = 1; d <= daysInMonth; d++) days.push(d);

    // Highlight only when viewing the picked date's month
    let pickedDay = picked.getFullYear() === year
      && picked.getMonth() === month ? picked.getDate() : 0;

    return [
      div(
        button({ onclick: () => {
          view.setMonth(month - 1);
          this.$({ view });
        }}, "‹"),
        `${new Date(year, month).toLocaleString('default',
          { month: 'long' })} ${year}`,
        button({ onclick: () => {
          view.setMonth(month + 1);
          this.$({ view });
        }}, "›")
      ),
      table(
        thead(tr(...["Su","Mo","Tu","We","Th","Fr","Sa"]
          .map(d => th(d)))),
        tbody(...Array.from(
          { length: Math.ceil(days.length / 7) }, (_, w) =>
          tr(...days.slice(w * 7, (w + 1) * 7).map(day =>
            td({
              class: day === pickedDay ? "selected" : "",
              onclick: () => {
                if (!day) return;
                this.$({
                  picked: new Date(year, month, day),
                  view
                });
                onSelect?.(new Date(year, month, day));
              }
            }, day || "")
          ))
        ))
      )
    ];
  }
});

document.body.render(mx.datePicker());

Todo Example

A classic Todo app in mx. State lives in local variables; the entire view re-renders on every change. mx handles minimal DOM updates under the hood.

let { div, h3, input, button, ul, li, span } = mx;

let todos = [];
let text = "";

function TodoApp(){
  return div({ class:"todo" },
    h3("Todos"),
    div(
      input({ ".value": text, oninput: e => {
        text = e.target.value;
        todoHost.render(TodoApp());
      }}),
      button({ onclick: _ => {
        if (text.trim()) {
          todos.push({ text, done: false });
          text = "";
          todoHost.render(TodoApp());
        }
      } }, "Add")
    ),
    ul( ...todos.map((t, i) =>
      li({ class: t.done ? "done" : "" },
        input({ type:"checkbox", ".checked": t.done, onchange: _ => {
          t.done = !t.done;
          todoHost.render(TodoApp());
        }}),
        span(t.text),
        button({ onclick: _ => {
          todos.splice(i, 1);
          todoHost.render(TodoApp());
        } }, "×")
      )
    ))
  );
}

let todoHost = dom.div();
todoHost.render(TodoApp());
document.body.render(todoHost);

Drag & Drop Reordering

HTML5 drag events work naturally with mx. A reorderable list component tracks the dragged index in $state and splices on drop.

define('drag-list', {
  $({ items = [], onReorder }) {
    let { div, span } = mx;

    return items.map((item, i) =>
      div({
        class: 'drag-item',
        draggable: 'true',
        ondragstart: () => { this.$state.dragging = i; },
        ondragover: e => e.preventDefault(),
        ondrop: () => {
          let arr = [...items];
          let [moved] = arr.splice(this.$state.dragging, 1);
          arr.splice(i, 0, moved);
          this.$({ items: arr });
          onReorder?.(arr);
        }
      },
        span({ class: 'handle' }, '⋮⋮'),
        span(item)
      )
    );
  }
});

document.body.render(
  mx.dragList({
    items: ['Learn mx', 'Build components', 'Ship to prod', 'Profit']
  })
);

Tip

Use ondragstart to store the source index, ondragover with e.preventDefault() to allow dropping, and ondrop to splice the array and re-render.

File Drop Zone

A drop zone component combines drag events with a hidden file input. Events are assigned directly on this so re-renders overwrite the same property instead of stacking listeners.

define('drop-zone', {
  $({ ondrop, accept }) {
    let { div, input, span } = mx;
    let handleFiles = files => ondrop?.(Array.from(files));

    // Direct assignment - safe on re-render (overwrites, never stacks)
    this.ondragover = e => {
      e.preventDefault();
      this.classList.add('dragging');
    };
    this.ondragleave = e => {
      if (e.target === this) this.classList.remove('dragging');
    };
    this.ondrop = e => {
      e.preventDefault();
      this.classList.remove('dragging');
      if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
    };

    return div({ class: 'drop-area',
      onclick: _ => this.querySelector('input').click()
    },
      span({ class: 'drop-text' }, 'Drop files here'),
      span({ class: 'drop-hint' }, 'or click to select'),
      input({ type:'file', multiple:true, accept,
        style: 'display:none',
        onchange(e) {
          if (e.target.files.length) {
            handleFiles(e.target.files);
            e.target.value = '';
          }
        }
      })
    );
  }
});

document.body.render(
  mx.dropZone({
    accept: 'image/*',
    ondrop(files) { alert(files.map(f => f.name).join(', ')); }
  })
);

Key pattern

Drag events are assigned directly on this (this.ondragover, this.ondrop, etc.) because they need to be on the component element itself. Direct assignment is safe on re-render - it overwrites the previous handler instead of stacking listeners. The visual content is returned as mx children.

Progress Bar (computed attrs)

Components can compute derived values from props and set attributes on themselves via this.$attrs(). This progress bar calculates completion percentage, urgency level, and sets attributes the CSS can target:

define('goal-progress', {
  $({ pledged = 0, goal = 1000, deadline }) {
    let pct = Math.min(100, Math.round(pledged / goal * 100));
    let days = deadline
      ? Math.ceil((new Date(deadline) - Date.now()) / 86400000)
      : null;

    // Set attributes for CSS styling
    this.$attrs({
      completed: pct >= 100 || null,
      urgency: days !== null && days <= 3 ? 'high'
             : days !== null && days <= 7 ? 'medium'
             : null
    });

    return [
      mx.div({ class: 'bar' },
        mx.div({ class: 'fill', style: `width:${pct}%` })
      ),
      mx.div({ class: 'stats' },
        mx.div(mx.strong('$' + pledged.toLocaleString()),
          ' of $' + goal.toLocaleString()),
        days !== null
          ? mx.div(mx.strong(days), ' days to go')
          : null,
        mx.div(mx.strong(pct + '%'), ' funded')
      )
    ];
  }
});

document.body.render(
  mx.goalProgress({
    pledged: 30000, goal: 50000,
    deadline: '2026-04-01'
  })
);

$attrs for CSS hooks

this.$attrs({ urgency: "high" }) sets urgency="high" on the element. Your CSS can then target goal-progress[urgency="high"] for styling. Passing null or false removes the attribute. This is cleaner than toggling class names manually.