
В статье представлено всё необходимое, чтобы осуществить вынесенное в заголовок (плюс поддержка сенсорного ввода), а так же готовое open source решение, которое можно просто подключить и пользоваться.
Неожиданное решение "из коробки"

При написании статьи неожиданно наткнулся на CSS-свойство resize, которое делает почти то, что нам нужно. Хотя не обошлось без нескольких "ложек дёгтя".
Страница на MDN https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/resize
Для использования необходимо указать CSS-свойство "resize" у целевого блока, вписав:
horizontal - для включения возможности изменения размеров по горизонтали;
vertical - для изменения по вертикали;
both - оба случая.
Для работы нужно тянуть только за значок треугольника, расположенный в правом нижнем углу блока.
Минусы:
на момент написания статьи, поддержка браузерами ограничивается только самыми свежими десктопными версиями Firefox и Chrome (и его производными);
управление осуществляется не с помощью границ сторон и углов, а только путём взаимодействия со значком в правом нижнем углу, что делает не интуитивным и, вполне возможно, крайне неудобным изменения размеров блоков, расположенных, к примеру, с права или внизу страницы;
не понятно, как настраивается внешний вид того значка треугольника, печаль для дизайнеров (если будет так же, как с полосами прокрутки, то дело обстоит - "очень не очень");
дополнительные ограничения на поддерживаемые CSS-свойства для блоков.
Впрочем, для ряда случаев такой подход может оказаться вполне приемлемым.
Решение с помощью JS-скрипта
Посмотреть "в живую" можно тут https://admtoha.is-a.dev/html/demo_resizable_blocks.html (синим цветом выделены активные стороны/углы).
Суть такова. Отслеживаем движение курсора мыши над целевым блоком. В случае попадания оного на активный край, определяемый с помощью координат внутри, меняем стиль курсора на подходящий. Как только пользователь нажал кнопку мыши (mousedown, touchstart), начинаем следить за движением курсора или аналога сенсорного ввода (mousemove, touchmove) по всей странице (document.body). При движении в направлении от блока увеличиваем оный, при движении в противоположную сторону - уменьшаем. В случае с абсолютным позиционированием целевого блока, и перемещением верхнего и нижнего краёв (а также прилегающих углов) помимо увеличения размера меняем также его координаты top и left, чтобы выглядело именно, как изменение размеров, а не изменение размеров плюс перемещение блока. Наконец, завершаем работу по увеличению/уменьшению при соответствующих событиях mouseup, touchend, touchcancel.
Нам понадобятся следующие функции, синглтон-объекты
get_node_style(node, style_prop[, pseudo_el]) - возвращает значения CSS-свойства с именем style_prop DOM-ноды node
scroll_locker - синглтон, который блокирует скроллинг страницы, что необходимо для корректной работы скрипта на мобильных устройствах, где движение по сенсорному экрану по умолчанию считывается, как сигнал к прокрутке
move_fix(mousemove_listener[, cursor_style][, extra_mouseup_listener]) - функция, назначение которой, убирать все мешающие негативные эффекты от движения по странице с зажатой левой клавишей мыши и аналогичного действия при сенсорном управлении (отключается выделение, прокрутка), а так же установить стиль курсора и передавать корректные данные в обработчик движения по странице mousemove_listener
make_resizable(node[, options]) - собственно та самая нужная нам функция, которая для ноды node создаёт активные для взаимодействия с пользователем зоны на краях сторон и углов согласно опциям options, то есть делает то, что вынесено в заголовок статьи
Рассмотрим код.
/*
Возвращает значение CSS-свойства style_prop для DOM-ноды node.
Может брать в качестве style_prop массив имён CSS-свойств,
в этом случае функцией возвращаться будет объект с ключами-именами свойств и значениям - соотв. значениями этих свойсв.
-------
Аргументы:
node (Object HTMLElement) - нода для анализа
style_prop (String or Array) - имя CSS-свойства либо их список
[ pseudo_el ] (string) - имя CSS псевдо-элемента
-------
Возвращает:
String or Object
-------
Examples:
get_node_style(node, 'transition-duration') // '0.3s'
get_node_style(node, 'height', '::before') // '10px'
get_node_style(node, ['transition-property', 'transition-duration']) // {'transition-property': 'all', 'transition-duration': '0.3s'}
*/
const get_node_style = (node, style_prop, pseudo_el) => {
if(typeof(style_prop) === 'string') return getComputedStyle(node, pseudo_el).getPropertyValue(style_prop);
else if(Array.isArray(style_prop)) return style_prop.reduce((acc, curr) => ({...acc, [curr] : getComputedStyle(node, pseudo_el).getPropertyValue(curr)}), {});
};
/*
Объект scroll_locker
Блокирует прокрутку window
-------
Методы:
.lock() - блокировка
Return:
undefined
-------
.unlock() - разблокировка
Return:
undefined
*/
const scroll_locker = {
old_value: null,
lock(){
this.old_value = get_node_style(document.body, 'overflow') || 'visible';
document.body.style.overflow = 'hidden';
},
unlock(){
document.body.style.overflow = this.old_value;
}
}
/*
Функция move_fix
Убирает все мешающие негативные эффекты от движения по странице с зажатей левой клавишей мыши
и аналогичного действия при сенсорном управлении (отключается выделение, прокрутка),
а так же устанавливает стиль курсора и передаёт корректные данные в обработчик движения по странице mousemove_listener;
-------
Аргументы:
mousemove_listener (Function) - обработчик движения мыши (+ аналог сенсорного управления)
[ cursor_style ] (String) - CSS стиль курсора, который устанавливается в мом��нт запуска функции
[ extra_mouseup_listener ] (Function) - обработчик "отжатия" левой кнопки мыши (+ аналог сенсорного управления)
Return:
undefined
*/
const move_fix = (mousemove_listener, cursor_style = null, extra_mouseup_listener = null) => {
let iframe_fix = null, touchmove_listener, mouseup_listener, current_event;
/* ищем iframe на странице; если найдены, покрываем всё пространство прозрачным div'ом;
если этого не сделать, то движения над ифреймами будут работать некорректно */
if(document.querySelector('iframe')){
iframe_fix = document.createElement('div');
iframe_fix.style = 'position: absolute; top: 0; left: 0; height: ' + document.body.offsetHeight + 'px; width: ' + document.body.offsetWidth + 'px;';
document.body.append(iframe_fix);
};
const move_listener = event => {
current_event = event;
window.requestAnimationFrame(() => mousemove_listener(current_event))
};
/* устанавливаем обработчики движения для document.body;
для сенсорного управления корректируем входящие данные */
document.body.addEventListener('mousemove', move_listener, {passive: true});
document.body.addEventListener('touchmove', touchmove_listener = event => {
if(event.changedTouches) event = event.changedTouches[0];
move_listener(event);
}, {passive: true});
/* орбабатываем прекращение движения, другими словами: завершение работы данной функции;
удаляем все обработчики, блокировки, чистим за собой */
document.body.addEventListener('mouseup', mouseup_listener = event => {
if(event.changedTouches) event = event.changedTouches[0];
/* удаляем все обработчики */
document.body.removeEventListener('mousemove', move_listener);
document.body.removeEventListener('touchmove', touchmove_listener);
document.body.removeEventListener('touchend', mouseup_listener);
document.body.removeEventListener('touchcancel', mouseup_listener);
document.body.removeEventListener('mouseup', mouseup_listener);
/* удаляем прозрачный div, который боролся с ифреймами, если тот есть */
if(iframe_fix){
iframe_fix.remove();
iframe_fix = null;
}
/* разблокируем прокрутку страницы */
scroll_locker.unlock();
/* разблокируем выделе��ие */
document.body.onselectstart = () => true;
/* запускаем обработчик mouseup, если требуется */
if(extra_mouseup_listener) extra_mouseup_listener(event);
document.body.style.cursor = 'auto';
}, {passive: true});
document.body.addEventListener('touchend', mouseup_listener, {passive: true});
document.body.addEventListener('touchcancel', mouseup_listener, {passive: true});
/* блокируем прокрутку */
scroll_locker.lock();
/* блокируем выделение на странице */
document.body.onselectstart = () => false;
/* устанавливаем курсор, если требуется */
if(cursor_style) document.body.style.cursor = cursor_style;
};
/*
Делает для DOM-ноды node возможность изменения размера путём взаимодействия курсора
или сенсорного аналога с активными зонами, создаваемыми согласно опциям options.
Схема активных зон:
left-top _ top _ right-top
| |
left right
| |
left-bottom _ bottom _ right-bottom
Список активных зон:
top - верхняя сторона
bottom - нижняя сторона
left - левая сторона
right - правая сторона
left-top - левый верхний угол
left-bottom - левый нижний угол
right-top - правый верхний угол
right-bottom - правый нижний угол
-------
Аргументы:
node - (HTMLElement) целевая нода
[ options ] - (Object) опции
{
zone_ls: (Array) - массив-список имён активных зон (по умолчанию: ['right', 'right-bottom', 'bottom'])
active_size: (Number Integer) - размер активных зон в пикселях (по умолчанию: 25)
}
-------
Возвращает:
undefined
-------
Примеры:
make_resizable(document.getElementById('resizable_div'), {zone_ls: ['right', 'right-bottom']})
make_resizable(document.getElementById('resizable_div'), {active_size: 30})
*/
const make_resizable = (node, options = {}) => {
if(!options.zone_ls) options.zone_ls = ['right', 'right-bottom', 'bottom']; // устанавливаем активные зоны по умолчанию
if(!options.active_size) options.active_size = 25; // устанавливаем размер активных зон по умолчанию
const node_style = get_node_style(node, ['min-height', 'min-width', 'display', 'border-left-width', 'border-right-width', 'border-top-width', 'border-bottom-width', 'position']);
/* устанавливаем минимальные размеры */
options.min_height = parseInt(node_style['min-height']) || 100;
options.min_width = parseInt(node_style['min-width']) || 100;
const
left_border_size = parseInt(node_style['border-left-width']) || 0,
right_border_size = parseInt(node_style['border-right-width']) || 0,
top_border_size = parseInt(node_style['border-top-width']) || 0,
bottom_border_size = parseInt(node_style['border-bottom-width']) || 0,
mode_position_absolute = ['fixed', 'absolute'].includes(node_style['position']) || 0; // выясняем, абсолютное ли позиционирование у целевой ноды
if(mode_position_absolute) node.style.setProperty('margin', '0'); // отключаем отступы для ноды с абсолютным позиционированием (без этого будут баги)
node.style.setProperty('box-sizing', 'border-box'); // необходимо для корректного вычисления размеров для нод с рамкой
if(node.parentNode && get_node_style(node.parentNode, 'display') === 'flex') node.style.setProperty('flex-shrink', 0); // для случая с размещения ноды во flex-контейнере
let
mode = null,
lock_mode = false,
move_listener;
/* определяем активную зону и устанавливаем соответствующий стиль курсора */
node.addEventListener('mousemove', move_listener = event => {
if((event.target !== node && node.style.cursor === 'auto') || lock_mode) return;
let
x = event.offsetX,
y = event.offsetY,
w = node.offsetWidth,
h = node.offsetHeight;
mode =
options.zone_ls.includes('left-top') && y + top_border_size <= options.active_size && x + left_border_size <= options.active_size ? 'left-top'
: options.zone_ls.includes('right-bottom') && (h - y - bottom_border_size) <= options.active_size && (w - x - right_border_size) <= options.active_size ? 'right-bottom'
: options.zone_ls.includes('right-top') && y + top_border_size <= options.active_size && (w - x - right_border_size) <= options.active_size ? 'right-top'
: options.zone_ls.includes('left-bottom') && (h - y - bottom_border_size) <= options.active_size && x + left_border_size <= options.active_size ? 'left-bottom'
: options.zone_ls.includes('top') && y + top_border_size <= options.active_size ? 'top'
: options.zone_ls.includes('bottom') && (h - y - bottom_border_size) <= options.active_size ? 'bottom'
: options.zone_ls.includes('left') && x + left_border_size <= options.active_size ? 'left'
: options.zone_ls.includes('right') && (w - x - right_border_size) <= options.active_size ? 'right'
: null
;
const cursor_style =
['left-top', 'right-bottom'].includes(mode) ? 'nw-resize'
: ['right-top', 'left-bottom'].includes(mode) ? 'ne-resize'
: ['top', 'bottom'].includes(mode) ? 'n-resize'
: ['left', 'right'].includes(mode) ? 'w-resize'
: 'auto'
;
if(node.style.cursor != cursor_style) node.style.cursor = cursor_style;
}, {passive: true});
node.addEventListener('touchmove', move_listener, {passive: true});
let mousedown_listener;
/* активация активной зоны (mousedown или touchstart) */
node.addEventListener('mousedown', mousedown_listener = event => {
if(event.target !== node) return; // исправление для Firefox; чесно говоря уже не помню, в каких обстоятельствах оно необходимо и актуально ли на сегодня вообще
if(node.offsetTop + node.offsetHeight >= document.body.offsetHeight - 20) document.body.style.height = (node.offsetTop + node.offsetHeight + 20) + 'px';
if(event.changedTouches){
event = event.changedTouches[0];
event.offsetX = event.pageX - node.getBoundingClientRect().left - window.scrollX - left_border_size + 1;
event.offsetY = event.pageY - node.getBoundingClientRect().top - window.scrollY - top_border_size + 1;
move_listener(event);
}
if(mode !== null) lock_mode = true;
/* устанавливаем начальные позиции и размеры целевой ноды и курсора */
let
x = event.offsetX,
y = event.offsetY,
w = node.offsetWidth,
h = node.offsetHeight,
t = node.offsetTop,
l = node.offsetLeft,
dx = event.pageX,
dy = event.pageY,
mousemove_listener,
mouseup_listener;
/* обрабатываемы движение */
if(mode !== null) move_fix(event => {
if(event.changedTouches) event = event.changedTouches[0];
let
xx = event.pageX - dx, // сдвиг по горизонтали
yy = event.pageY - dy; // сдвиг по вертикали
switch(mode){
case 'left-top': // левый верхний угол
if((xx < 0) || ((xx > 0) && (w - xx >= options.min_width))){
if(mode_position_absolute) node.style.left = l + xx + 'px'; // в случае с абсолютным позиционированием изменяем значение свойства left ноды
node.style.width = w - xx + 'px'; // изменяем ширину целевой ноды
}
if((yy < 0) || ((yy > 0) && (h - yy >= options.min_height))){
if(mode_position_absolute) node.style.top = t + yy + 'px'; // в случае с абсолютным позиционированием изменяем значение свойства top ноды
node.style.height = h - yy + 'px'; // изменяем высоту целевой ноды
}
break;
case 'top': // верхняя сторона
if((yy < 0) || ((yy > 0) && (h - yy >= options.min_height))){
if(mode_position_absolute) node.style.top = t + yy + 'px';
node.style.height = h - yy + 'px';
}
break;
case 'right-top': // правый верхний угол
if(((xx > 0) || (xx < 0)) && (w + xx) >= options.min_width) node.style.width = w + xx + 'px';
if((yy < 0) || ((yy > 0) && (h - yy >= options.min_height))){
if(mode_position_absolute) node.style.top = t + yy + 'px';
node.style.height = h - yy + 'px';
}
break;
case 'right': // правая сторона
if(((xx > 0) || (xx < 0)) && (w + xx) >= options.min_width) node.style.width = w + xx + 'px';
break;
case 'right-bottom': // провый нижний угол
if(event.pageY >= document.body.offsetHeight - 20) document.body.style.height = event.pageY + 20 + 'px';
if(((xx > 0) || (xx < 0)) && (w + xx) >= options.min_width) node.style.width = w + xx + 'px';
if(((yy > 0) || (yy < 0)) && (h + yy) >= options.min_height) node.style.height = h + yy + 'px';
break;
case 'bottom': // нижняя сторона
if(event.pageY >= document.body.offsetHeight - 20) document.body.style.height = event.pageY + 20 + 'px';
if(((yy > 0) || (yy < 0)) && (h + yy) >= options.min_height) node.style.height = h + yy + 'px';
break;
case 'left-bottom': // левый нижний угол
if(event.pageY >= document.body.offsetHeight - 20) document.body.style.height = event.pageY + 20 + 'px';
if((xx < 0) || ((xx > 0) && (w - xx >= options.min_width))){
if(mode_position_absolute) node.style.left = l + xx + 'px';
node.style.width = w - xx + 'px';
}
if(((yy > 0) || (yy < 0)) && (h + yy) >= options.min_height) node.style.height = h + yy + 'px';
break;
case 'left': // левая сторона
if((xx < 0) || ((xx > 0) && (w - xx >= options.min_width))){
if(mode_position_absolute) node.style.left = l + xx + 'px';
node.style.width = w - xx + 'px';
}
break;
}
}, node.style.cursor, () => lock_mode = false);
}, {passive: true});
node.addEventListener('touchstart', mousedown_listener, {passive: true});
};Замечание. Наличие полос прокрутки у целевой ноды влияет на доступность активных зон, так как оные эти зоны перекрывают. Поэтому учитывайте это, делая поправку на размер активных зон и размер рамки. Без ущерба для дизайна можно сделать достаточно широкую прозрачную рамку (border-right) со стороны с активной зоной и полосой прокрутки.
Поддержка браузерами. Проверено в десктопных и мобильных версиях Firefox и Chrome и их основных "производных". Браузеры семейства Safari протестировать к сожалению не удалось. (Как появится возможность, проверю и обновлю этот пункт)
Готовое решение
Называется resizable_blocks.
Лежит на Гитхабе здесь https://github.com/admtoha/resizable_blocks
Интерактивная демонстрация https://admtoha.is-a.dev/html/demo_resizable_blocks.html

Как пользоваться
Подключите файл resizable_blocks.js к вашей странице
<!-- Классическое подкюлчение ES модуля -->
<script language="JavaScript" type="module" src="./resizable_blocks.js"></script>
<!-- Или с помощью скрипта JS -->
<script language="JavaScript" type="module">
import make_resizable from './resizable_blocks.js';
// или так:
const make_resizable = await import('./resizable_blocks.js');
</script>Укажите у целевых блоков атрибут "data-resizable-blocks" и заполните опциями его значение согласно вашим предпочтениям, указав через запятую активные (доступные для взаимодействия с пользователем) стороны и углы.
Пример:
<!-- Устанавливаются активными правая сторона, нижняя сторона и правый нижний угол -->
<div data-resizable-blocks='right, bottom, right-bottom'> ... </div>Все доступные опции:
top - верхняя сторона блока
right - правая сторона
bottom - нижняя сторона
left - левая сторона
left-top - левый верхний угол
left-bottom - левый нижний угол
right-top - правый верхний угол
right-bottom - правый нижний угол
active_size: size* - размер "активной зоны" в пикселях (по умолчанию - 25); имеется ввиду размер того доступного пространства с края целевого блока, которое является интерактивным
remember - опция позволяет запоминать последние размеры блока и автоматически восстанавливать соответствующие высоту и ширину при перезагрузке страницы или аналогичном динамическом изменением страницы
По умолчанию, если активные стороны и углы не заданы, устанавливается значение: right, bottom, right-bottom.
Поддерживается отслеживание динамических изменений страницы.
Вы можете программно создать и добавить блок с атрибутом "data-resizable-blocks" на страницу и скрипт его "подхватит".
// Пример:
const container = document.createElement('div');
container.id = 'resizable_div';
container.setAttribute('data-resizable-blocks', 'right, remember');
document.body.append(container);Можно также для уже находящегося на странице блока включить скрипт, просто установив ему соответствующий атрибут.
// Пример:
document.getElementById('resizable_div').setAttribute('data-resizable-blocks', 'right, bottom, remember');Присутствует возможность отключения скрипта для блока, для этого просто удалите у него атрибут "data-resizable-blocks".
// Пример:
document.getElementById('resizable_div').removeAttribute('data-resizable-blocks');Вы можете также "на лету" изменять поведение скрипта для отдельных блоков, для этого нужно просто установить новое соответствующее значение для атрибута "data-resizable-blocks".
// Пример:
document.getElementById('resizable_div').setAttribute('data-resizable-blocks', 'right');
// ... Делаем что-нибудь и затем изменяем поведение скрипта для блока.
document.getElementById('resizable_div').setAttribute('data-resizable-blocks', 'right, bottom, right-bottom, remember');
При необходимости вы можете обратиться к функции обработки блоков напрямую:
make_resizable(node, options)
Arguments:
* node: (HTMLElement) - The target node.
* [options]: (Object) - Options.
* zone_ls: (Array) - List of active zone names (default: ['right', 'right-bottom', 'bottom']).
* active_size: (Number Integer) - Size/width of active zones in pixels (default: 25).
* remember: (Boolean) enables the ability to remember the block size. This means that upon page reload (or creation of a dynamic block with the same ID),
the last height and width values are automatically restored. Requires the target block to have an ID (default: false)
Return:
* (Object) - An object representing the controller, providing the following capabilities:
{
is_on: - (boolean) a flag indicating whether the script is enabled for the target block.
off(): - (function) disables the script.
Takes no arguments. Returns: undefined
on(): - (function) enables the script if it was previously disabled.
Takes no arguments. Returns: undefined
remake(new_options): - (function) replaces the options with new ones, effectively changing the script's behavior according to the new options.
Arguments:
new_options - (Object) the new options; essentially, these are the same options used for the make_resizable()
Return: undefined
}P.S. Для написания кода и статьи ИИ не использовался.
P.P.S. Телеграмм канала у меня нет, поэтому подписываться некуда, извините.
UPD. 27.12.2025 Доработал Готовое решение (рефакторинг под ES-модули, добавлено много новых возможностей) и соответственно изменил статью.