Here is a more complete alternative that allows dragging and resizing columns.
Put a file named adaptive-ha-device-page.js into /config/www/
Then put the entry into the configuration.yaml
Restart home assistant
Reload browser cache with ctrl+shift+r
frontend:
extra_module_url:
- /local/adaptive-ha-device-page.js
adaptive-ha-device-page.js
/* ================================================================
* Adaptive wrapper for Home Assistant device configuration page
* ----------------------------------------------------------------
* - Removes the 1000px width cap so the page uses your full monitor.
* - Per-column maxWidth / flexGrow / visibility.
* - Drag bars between columns (persist to localStorage; dbl-click resets).
* - Optional sub-column split inside the entity column.
*
* COLUMN LAYOUT (this is what HA renders, left to right):
* Column 1: Device Info + Related (Automations / Scenes / Scripts)
* Column 2: Entity cards (Controls, Sensors, Configs, Diagnostics)
* Column 3: Activity (Logbook)
*
* Device Info and Related share column 1 and stack vertically ā that's
* the HA template, not configurable from CSS.
*
* INSTALLATION
* 1. Save as /config/www/adaptive-ha-device-page.js
* 2. In configuration.yaml:
* frontend:
* extra_module_url:
* - /local/adaptive-ha-device-page.js
* 3. Restart Home Assistant, then hard-reload (Ctrl+Shift+R).
*
* To reset a manually-resized column: double-click its drag bar.
* To reset all columns: clear keys matching "adaptive-ha-device-page-*"
* from localStorage and reload.
* ================================================================ */
(() => {
"use strict";
// ============================ CONFIG ============================
const CONFIG = {
// Max width of the entire page area. 'none' = full screen width.
containerMaxWidth: "none",
// Per-column settings (1 = leftmost).
// maxWidth : hard upper bound on the column width
// flexGrow : relative share of leftover space
// visible : false hides the column
columns: {
1: { maxWidth: "400px", flexGrow: 1, visible: true },
2: { maxWidth: "none", flexGrow: 4, visible: true },
3: { maxWidth: "450px", flexGrow: 1, visible: true },
},
// Drag bars between columns. Drag = resize, dbl-click = reset.
// Resized widths persist via localStorage.
resizable: true,
// Split column 2 (entities) into multiple sub-columns. Uses CSS
// multi-column so heights auto-balance for tight packing.
// entitiesSubColumns : how many sub-columns to aim for
// entitiesSubColumnMinWidth: minimum width per sub-column; the
// browser collapses to fewer columns
// when there isn't room
entitiesSubColumns: 2,
entitiesSubColumnMinWidth: "320px",
// Minimum width a column can be dragged down to (px).
minColumnWidth: 200,
};
// ================================================================
const STYLE_ID = "adaptive-wrapper-device-page-styles";
const HANDLE_CLASS = "adaptive-resize-handle";
const LS_KEY = (idx) => `adaptive-ha-device-page-col${idx}-width`;
// ------------------------------ CSS ------------------------------
// Note: we use `div:nth-child(N of .column)` (CSS Selectors L4)
// rather than `.column:nth-of-type(N)`, because the container also
// holds a non-column header div as its first child. nth-of-type
// would count the header and target the wrong column.
const buildCss = () => {
const { containerMaxWidth, columns } = CONFIG;
const columnRule = (n) => {
const c = columns[n];
if (!c) return "";
const lines = [];
if (c.visible === false) lines.push("display: none !important;");
// Override original `width: 33%` so max-width is the real cap.
lines.push("width: auto;");
lines.push("flex-basis: 0;");
lines.push(`flex-grow: ${c.flexGrow ?? 1};`);
lines.push(`max-width: ${c.maxWidth ?? "none"};`);
// Without min-width: 0, flex items refuse to shrink below their
// content's min-content, which breaks flex-grow ratios.
lines.push("min-width: 0;");
return `
hass-subpage > .container > div:nth-child(${n} of .column) {
${lines.join("\n ")}
}`;
};
// Sub-column split for the entity column. Uses CSS multi-column
// (column-count) so heights auto-balance between sub-columns ā
// this gives tight packing without gaps under shorter cards.
//
// `contain: layout style` isolates the fragmentation so it can't
// delay dropdown popup anchoring (the popup-below-icon bug from
// earlier). A getBoundingClientRect flush after style injection
// reinforces this ā see applyAll() below.
const subColumnsRule =
CONFIG.entitiesSubColumns > 1
? `
hass-subpage > .container > div:nth-child(2 of .column) {
column-count: ${CONFIG.entitiesSubColumns};
column-width: ${CONFIG.entitiesSubColumnMinWidth};
column-gap: var(--ha-space-4, 16px);
column-fill: balance;
contain: layout style;
}
hass-subpage > .container > div:nth-child(2 of .column) > * {
break-inside: avoid;
-webkit-column-break-inside: avoid;
page-break-inside: avoid;
display: block;
width: 100%;
margin-top: 0 !important;
margin-bottom: var(--ha-space-4, 16px);
}`
: "";
const handleCss = CONFIG.resizable
? `
.${HANDLE_CLASS} {
flex: 0 0 8px;
align-self: stretch;
cursor: ew-resize;
position: relative;
background: transparent;
z-index: 1;
touch-action: none;
}
.${HANDLE_CLASS}::before {
content: '';
position: absolute;
top: var(--ha-space-2, 8px);
bottom: var(--ha-space-2, 8px);
left: 50%;
transform: translateX(-50%);
width: 2px;
background: var(--divider-color, rgba(127,127,127,0.35));
border-radius: 1px;
transition: background 120ms ease, width 120ms ease;
}
.${HANDLE_CLASS}:hover::before,
.${HANDLE_CLASS}:active::before {
background: var(--primary-color, #03a9f4);
width: 4px;
}
:host([narrow]) .${HANDLE_CLASS} {
display: none !important;
}`
: "";
return `
/* Page container */
hass-subpage > .container {
max-width: ${containerMaxWidth} !important;
}
/* Per-column rules */
${columnRule(1)}
${columnRule(2)}
${columnRule(3)}
/* Multi-column entity grid */
${subColumnsRule}
/* Drag handles */
${handleCss}
/* Mobile: stack everything vertically */
:host([narrow]) .container > .column {
width: 100% !important;
max-width: none !important;
column-count: 1 !important;
contain: none !important;
flex-basis: auto !important;
}
`;
};
// ----------------------- Drag handle logic -----------------------
const lockColumnWidth = (col, widthPx) => {
col.style.flex = `0 0 ${widthPx}px`;
col.style.maxWidth = "none";
col.style.width = `${widthPx}px`;
};
const unlockColumnWidth = (col) => {
[
"flex",
"flex-grow",
"flex-shrink",
"flex-basis",
"width",
"max-width",
].forEach((p) => col.style.removeProperty(p));
};
const setupResizeHandles = (devicePageEl) => {
if (!CONFIG.resizable) return;
const root = devicePageEl.shadowRoot;
if (!root) return;
const container = root.querySelector(".container");
if (!container) return;
const columns = Array.from(
container.querySelectorAll(":scope > .column")
);
if (columns.length < 2) return;
// Clean slate.
container
.querySelectorAll(`.${HANDLE_CLASS}`)
.forEach((h) => h.remove());
columns.forEach((col, idx) => {
// Restore any saved width for this column.
try {
const saved = parseFloat(localStorage.getItem(LS_KEY(idx)) || "");
if (!Number.isNaN(saved) && saved > 100) {
lockColumnWidth(col, saved);
}
} catch {
/* localStorage blocked ā ignore */
}
// Insert a handle after every column except the last.
if (idx === columns.length - 1) return;
const handle = document.createElement("div");
handle.className = HANDLE_CLASS;
handle.title = "Drag to resize ⢠Double-click to reset";
col.insertAdjacentElement("afterend", handle);
let startX = 0;
let startWidth = 0;
let dragging = false;
const onMove = (e) => {
if (!dragging) return;
const delta = e.clientX - startX;
const newWidth = Math.max(
CONFIG.minColumnWidth,
startWidth + delta
);
lockColumnWidth(col, newWidth);
};
const onUp = () => {
if (!dragging) return;
dragging = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
try {
localStorage.setItem(
LS_KEY(idx),
String(col.getBoundingClientRect().width)
);
} catch {
/* ignore */
}
};
handle.addEventListener("mousedown", (e) => {
startX = e.clientX;
startWidth = col.getBoundingClientRect().width;
dragging = true;
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
e.preventDefault();
});
handle.addEventListener("dblclick", () => {
unlockColumnWidth(col);
try {
localStorage.removeItem(LS_KEY(idx));
} catch {
/* ignore */
}
});
});
};
// -------------------------- Style injection ---------------------
const injectInto = (root, content) => {
if (!root) return;
let style = root.getElementById?.(STYLE_ID);
if (style) {
style.textContent = content;
return;
}
style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = content;
root.appendChild(style);
};
// Walk into shadow roots ā needed for already-mounted instances.
const findAll = (root, tag) => {
const out = [];
const lower = tag.toLowerCase();
const walk = (node) => {
if (!node) return;
if (node.nodeType === 1 && node.tagName?.toLowerCase() === lower) {
out.push(node);
}
if (node.shadowRoot) {
for (const child of node.shadowRoot.children) walk(child);
}
for (const child of node.children || []) walk(child);
};
walk(root);
return out;
};
const applyAll = (devicePageEl) => {
injectInto(devicePageEl.shadowRoot, buildCss());
// Run on next frame so the columns exist in the DOM.
requestAnimationFrame(() => {
setupResizeHandles(devicePageEl);
// Force a synchronous layout pass so the multi-column
// fragmentation has fully settled before the user can open
// any dropdowns. Without this, getBoundingClientRect() in
// HA's dropdown library can return a stale rect and anchor
// the popup a few pixels below where the icon ends up.
const c = devicePageEl.shadowRoot?.querySelector(".container");
if (c) void c.getBoundingClientRect();
});
};
// -------------------------- Patch the element -------------------
customElements.whenDefined("ha-config-device-page").then(() => {
const DevicePage = customElements.get("ha-config-device-page");
if (!DevicePage) {
console.warn("[adaptive-device-page] element not found");
return;
}
const origFirstUpdated = DevicePage.prototype.firstUpdated;
DevicePage.prototype.firstUpdated = function (...args) {
const r = origFirstUpdated?.apply(this, args);
applyAll(this);
return r;
};
// If HA re-renders and our handles get blown away, put them back.
const origUpdated = DevicePage.prototype.updated;
DevicePage.prototype.updated = function (...args) {
const r = origUpdated?.apply(this, args);
if (CONFIG.resizable) {
const c = this.shadowRoot?.querySelector(".container");
if (c && !c.querySelector(`.${HANDLE_CLASS}`)) {
requestAnimationFrame(() => setupResizeHandles(this));
}
}
return r;
};
// Patch any instance already on the page when the script loads.
findAll(document.body, "ha-config-device-page").forEach(applyAll);
console.info("[adaptive-device-page] loaded");
});
})();