This document specifies the implementation plan for adding a slider control to adjust stroke width continuously, replacing or supplementing the current discrete 3-button selection (Thin/Bold/Extra Bold). This provides users with fine-grained control over stroke width for more precise drawing.
File: packages/common/src/constants.ts (lines 402-406)
export const STROKE_WIDTH = {
thin: 1,
bold: 2,
extraBold: 4,
} as const;Current limitations:
- Only 3 discrete values: 1, 2, and 4
- No intermediate values (e.g., 1.5, 3, 5+)
- Users cannot fine-tune stroke width for precise work
File: packages/common/src/constants.ts (lines 408-426)
export const DEFAULT_ELEMENT_PROPS = {
// ...
strokeWidth: 2, // Default is "bold"
// ...
};File: packages/excalidraw/types.ts (line 339)
currentItemStrokeWidth: number;File: packages/excalidraw/appState.ts (lines 43, 174)
// Initialization
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
// Persistence config - saved to browser localStorage
currentItemStrokeWidth: { browser: true, export: false, server: false },File: packages/excalidraw/actions/actionProperties.tsx (lines 544-600)
The current implementation uses RadioSelection component with 3 fixed options:
export const actionChangeStrokeWidth = register<
ExcalidrawElement["strokeWidth"]
>({
name: "changeStrokeWidth",
label: "labels.strokeWidth",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { strokeWidth: value }),
),
appState: { ...appState, currentItemStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset>
<legend>{t("labels.strokeWidth")}</legend>
<div className="buttonList">
<RadioSelection
group="stroke-width"
options={[
{ value: STROKE_WIDTH.thin, text: t("labels.thin"), icon: StrokeWidthBaseIcon },
{ value: STROKE_WIDTH.bold, text: t("labels.bold"), icon: StrokeWidthBoldIcon },
{ value: STROKE_WIDTH.extraBold, text: t("labels.extraBold"), icon: StrokeWidthExtraBoldIcon },
]}
value={getFormValue(...)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
});File: packages/excalidraw/components/Range.tsx
The opacity slider provides a reference implementation:
export type RangeProps = {
updateData: (value: number) => void;
app: AppClassProperties;
testId?: string;
};
export const Range = ({ updateData, app, testId }: RangeProps) => {
// Uses <input type="range" min="0" max="100" step="10" />
// Shows value bubble below slider
// Animated gradient background showing progress
};Key features:
- Input range with min/max/step configuration
- Value bubble showing current value
- Gradient background indicating progress
- Handles mixed selection states
File: packages/excalidraw/components/Range.scss
.range-wrapper {
position: relative;
padding-top: 10px;
padding-bottom: 25px;
}
.range-input {
width: 100%;
height: 4px;
background: var(--color-slider-track);
// Custom thumb styling for webkit and moz
}
.value-bubble {
position: absolute;
bottom: 0;
transform: translateX(-50%);
font-size: 12px;
}File: packages/element/src/types.ts (lines 40-82)
type _ExcalidrawElementBase = Readonly<{
// ...
strokeWidth: number; // Already supports any numeric value
// ...
}>;The element type already supports any numeric stroke width value - only the UI restricts it to 3 options.
File: packages/excalidraw/components/App.tsx (lines 8230-8247)
const element = newFreeDrawElement({
// ...
strokeWidth: this.state.currentItemStrokeWidth,
// ...
});Create a new StrokeWidthSlider component that:
- Provides continuous stroke width control via slider
- Keeps preset buttons for quick selection (Thin/Bold/Extra Bold)
- Shows current value numerically
- Supports a reasonable range (1-16) with step of 1
File: packages/excalidraw/components/StrokeWidthSlider.tsx (new file)
import React, { useEffect, useRef } from "react";
import { STROKE_WIDTH } from "@excalidraw/common";
import { t } from "../i18n";
import "./StrokeWidthSlider.scss";
import type { AppClassProperties } from "../types";
// Stroke width configuration
const STROKE_WIDTH_MIN = 1;
const STROKE_WIDTH_MAX = 16;
const STROKE_WIDTH_STEP = 1;
export type StrokeWidthSliderProps = {
value: number;
onChange: (value: number) => void;
testId?: string;
};
export const StrokeWidthSlider = ({
value,
onChange,
testId,
}: StrokeWidthSliderProps) => {
const rangeRef = useRef<HTMLInputElement>(null);
const valueRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current;
const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 16;
const percentage = (value - STROKE_WIDTH_MIN) / (STROKE_WIDTH_MAX - STROKE_WIDTH_MIN);
const position = percentage * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`;
const percentFill = percentage * 100;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${percentFill}%, var(--button-bg) ${percentFill}%, var(--button-bg) 100%)`;
}
}, [value]);
return (
<div className="stroke-width-slider">
<div className="stroke-width-presets">
<button
type="button"
className={`preset-button ${value === STROKE_WIDTH.thin ? "active" : ""}`}
onClick={() => onChange(STROKE_WIDTH.thin)}
title={t("labels.thin")}
>
{STROKE_WIDTH.thin}
</button>
<button
type="button"
className={`preset-button ${value === STROKE_WIDTH.bold ? "active" : ""}`}
onClick={() => onChange(STROKE_WIDTH.bold)}
title={t("labels.bold")}
>
{STROKE_WIDTH.bold}
</button>
<button
type="button"
className={`preset-button ${value === STROKE_WIDTH.extraBold ? "active" : ""}`}
onClick={() => onChange(STROKE_WIDTH.extraBold)}
title={t("labels.extraBold")}
>
{STROKE_WIDTH.extraBold}
</button>
</div>
<div className="range-wrapper">
<input
ref={rangeRef}
type="range"
min={STROKE_WIDTH_MIN}
max={STROKE_WIDTH_MAX}
step={STROKE_WIDTH_STEP}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value}
</div>
</div>
</div>
);
};File: packages/excalidraw/components/StrokeWidthSlider.scss (new file)
@use "../css/variables.module" as *;
.excalidraw {
.stroke-width-slider {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stroke-width-presets {
display: flex;
gap: 0.25rem;
justify-content: center;
.preset-button {
@include outlineButtonIconStyles;
min-width: 2rem;
font-size: 0.75rem;
font-weight: 500;
&.active {
background-color: var(--color-surface-primary-container);
border-color: var(--color-brand-hover);
}
}
}
.stroke-width-slider .range-wrapper {
position: relative;
padding-top: 8px;
padding-bottom: 20px;
}
.stroke-width-slider .range-input {
width: 100%;
height: 4px;
-webkit-appearance: none;
background: var(--color-slider-track);
border-radius: 2px;
outline: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: var(--color-slider-thumb);
border-radius: 50%;
cursor: pointer;
border: none;
}
&::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--color-slider-thumb);
border-radius: 50%;
cursor: pointer;
border: none;
}
}
.stroke-width-slider .value-bubble {
position: absolute;
bottom: 0;
transform: translateX(-50%);
font-size: 11px;
color: var(--text-primary-color);
font-weight: 500;
}
}File: packages/excalidraw/actions/actionProperties.tsx
Modify the actionChangeStrokeWidth to use the new slider component:
import { StrokeWidthSlider } from "../components/StrokeWidthSlider";
export const actionChangeStrokeWidth = register<
ExcalidrawElement["strokeWidth"]
>({
name: "changeStrokeWidth",
label: "labels.strokeWidth",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeWidth: value,
}),
),
appState: { ...appState, currentItemStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const value = getFormValue(
elements,
app,
(element) => element.strokeWidth,
(element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidth,
);
return (
<fieldset>
<legend>{t("labels.strokeWidth")}</legend>
<StrokeWidthSlider
value={value ?? appState.currentItemStrokeWidth}
onChange={(newValue) => updateData(newValue)}
testId="strokeWidth-slider"
/>
</fieldset>
);
},
});File: packages/common/src/constants.ts
Add constants for stroke width range:
// Add after existing STROKE_WIDTH constant
export const STROKE_WIDTH_RANGE = {
min: 1,
max: 16,
step: 1,
} as const;File: packages/excalidraw/components/StrokeWidthSlider.tsx
Ensure proper exports.
File: packages/excalidraw/index.tsx (if needed for public API)
Add export if the component should be part of the public API.
| File | Purpose |
|---|---|
packages/excalidraw/components/StrokeWidthSlider.tsx |
New slider component |
packages/excalidraw/components/StrokeWidthSlider.scss |
Slider styles |
| File | Changes |
|---|---|
packages/excalidraw/actions/actionProperties.tsx |
Update PanelComponent to use StrokeWidthSlider |
packages/common/src/constants.ts |
Add STROKE_WIDTH_RANGE constant |
The following do not need modification:
- Element types - Already supports numeric strokeWidth
- App state - Already stores numeric value
- Element creation - Already uses numeric value from state
- Serialization - Already handles numeric values
File: packages/excalidraw/tests/strokeWidthSlider.test.tsx (new file)
import React from "react";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { Pointer, UI } from "./helpers/ui";
import { act, fireEvent, render, screen } from "./test-utils";
import type { ExcalidrawElement } from "@excalidraw/element/types";
const { h } = window;
const mouse = new Pointer("mouse");
describe("Stroke Width Slider", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
afterEach(async () => {
await act(async () => {});
});
describe("Slider Control", () => {
it("should update stroke width when slider value changes", async () => {
// Create a rectangle
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
const element = h.elements[0];
expect(element.strokeWidth).toBe(2); // Default
// Find and change slider
const slider = screen.getByTestId("strokeWidth-slider");
fireEvent.change(slider, { target: { value: "8" } });
const updatedElement = h.elements[0];
expect(updatedElement.strokeWidth).toBe(8);
});
it("should allow continuous values between presets", async () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
const slider = screen.getByTestId("strokeWidth-slider");
// Test value between thin (1) and bold (2)
fireEvent.change(slider, { target: { value: "3" } });
expect(h.elements[0].strokeWidth).toBe(3);
// Test value between bold (2) and extraBold (4)
fireEvent.change(slider, { target: { value: "5" } });
expect(h.elements[0].strokeWidth).toBe(5);
// Test value beyond extraBold
fireEvent.change(slider, { target: { value: "12" } });
expect(h.elements[0].strokeWidth).toBe(12);
});
it("should respect min and max bounds", async () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
const slider = screen.getByTestId("strokeWidth-slider") as HTMLInputElement;
expect(slider.min).toBe("1");
expect(slider.max).toBe("16");
});
});
describe("Preset Buttons", () => {
it("should set stroke width to thin (1) when thin preset clicked", async () => {
API.setAppState({ currentItemStrokeWidth: 8 });
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
// Click thin preset button
const thinButton = screen.getByTitle("Thin");
fireEvent.click(thinButton);
expect(h.elements[0].strokeWidth).toBe(1);
});
it("should set stroke width to bold (2) when bold preset clicked", async () => {
API.setAppState({ currentItemStrokeWidth: 8 });
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
const boldButton = screen.getByTitle("Bold");
fireEvent.click(boldButton);
expect(h.elements[0].strokeWidth).toBe(2);
});
it("should set stroke width to extraBold (4) when extraBold preset clicked", async () => {
API.setAppState({ currentItemStrokeWidth: 1 });
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
const extraBoldButton = screen.getByTitle("Extra bold");
fireEvent.click(extraBoldButton);
expect(h.elements[0].strokeWidth).toBe(4);
});
it("should highlight active preset button", async () => {
API.setAppState({ currentItemStrokeWidth: 2 });
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
const boldButton = screen.getByTitle("Bold");
expect(boldButton.classList.contains("active")).toBe(true);
});
});
describe("App State", () => {
it("should update currentItemStrokeWidth when slider changes", async () => {
expect(h.state.currentItemStrokeWidth).toBe(2); // Default
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
const slider = screen.getByTestId("strokeWidth-slider");
fireEvent.change(slider, { target: { value: "10" } });
expect(h.state.currentItemStrokeWidth).toBe(10);
});
it("should create new elements with current stroke width", async () => {
API.setAppState({ currentItemStrokeWidth: 7 });
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
expect(h.elements[0].strokeWidth).toBe(7);
});
});
describe("FreeDraw/Pen Tool", () => {
it("should apply stroke width to freedraw elements", async () => {
API.setAppState({ currentItemStrokeWidth: 6 });
UI.clickTool("freedraw");
mouse.down(10, 10);
mouse.moveTo(50, 50);
mouse.up(100, 100);
const freedrawElement = h.elements[0];
expect(freedrawElement.type).toBe("freedraw");
expect(freedrawElement.strokeWidth).toBe(6);
});
it("should update freedraw stroke width via slider", async () => {
UI.clickTool("freedraw");
mouse.down(10, 10);
mouse.moveTo(50, 50);
mouse.up(100, 100);
const slider = screen.getByTestId("strokeWidth-slider");
fireEvent.change(slider, { target: { value: "14" } });
expect(h.elements[0].strokeWidth).toBe(14);
});
});
describe("Multiple Selection", () => {
it("should update stroke width for all selected elements", async () => {
// Create two rectangles
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(50, 50);
UI.clickTool("rectangle");
mouse.down(100, 100);
mouse.up(150, 150);
// Select both
API.setSelectedElements([h.elements[0], h.elements[1]]);
// Change stroke width
const slider = screen.getByTestId("strokeWidth-slider");
fireEvent.change(slider, { target: { value: "9" } });
expect(h.elements[0].strokeWidth).toBe(9);
expect(h.elements[1].strokeWidth).toBe(9);
});
});
});Add to existing snapshot tests:
it("renders element with custom stroke width correctly", async () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
strokeWidth: 8,
});
API.setElements([rect]);
expect(h.elements).toMatchSnapshot();
});
it("renders freedraw with custom stroke width correctly", async () => {
const freedraw = API.createElement({
type: "freedraw",
x: 0,
y: 0,
points: [[0, 0], [50, 50], [100, 0]],
strokeWidth: 12,
});
API.setElements([freedraw]);
expect(h.elements).toMatchSnapshot();
});Scenarios to test:
-
Slider interaction
- Drag slider to various positions
- Verify element stroke width updates in real-time
- Verify value bubble shows correct value
-
Preset button interaction
- Click each preset button
- Verify slider position updates
- Verify element stroke width matches preset
-
Persistence
- Set custom stroke width
- Refresh page
- Verify stroke width persists (localStorage)
-
Undo/Redo
- Change stroke width
- Undo
- Verify reverts to previous value
- Redo
- Verify restored
-
Copy/Paste styles
- Create element with custom stroke width
- Copy styles
- Paste to another element
- Verify stroke width transferred
- Slider renders correctly in properties panel
- Slider thumb moves smoothly
- Value bubble shows current value and follows thumb
- Preset buttons highlight when active
- Clicking preset updates slider position
- Stroke width changes are visible on canvas immediately
- Works with all element types that have stroke (rectangle, ellipse, arrow, line, freedraw)
- Works in dark mode
- Works on mobile/touch devices
- Keyboard accessibility (arrow keys adjust value)
- RTL layout displays correctly
# Run all tests with snapshot updates
yarn test:update
# Run specific stroke width tests
yarn test packages/excalidraw/tests/strokeWidthSlider.test.tsx
# Type checking
yarn test:typecheck
# Lint and format
yarn fix-
Phase 1: Component Creation
- Create
StrokeWidthSlider.tsxcomponent - Create
StrokeWidthSlider.scssstyles - Add
STROKE_WIDTH_RANGEconstant
- Create
-
Phase 2: Integration
- Update
actionChangeStrokeWidthPanelComponent - Import and use new slider component
- Update
-
Phase 3: Testing
- Write unit tests
- Write integration tests
- Run full test suite
- Manual QA testing
-
Phase 4: Polish
- Verify accessibility
- Test dark mode
- Test RTL layout
- Performance check (slider drag smoothness)
| Purpose | File Path |
|---|---|
| Stroke width constants | packages/common/src/constants.ts:402-406 |
| Default element props | packages/common/src/constants.ts:408-426 |
| Current stroke width action | packages/excalidraw/actions/actionProperties.tsx:544-600 |
| App state definition | packages/excalidraw/types.ts:339 |
| App state initialization | packages/excalidraw/appState.ts:43 |
| Opacity Range component (reference) | packages/excalidraw/components/Range.tsx |
| Range styles (reference) | packages/excalidraw/components/Range.scss |
| Element base type | packages/element/src/types.ts:40-82 |
| FreeDraw element type | packages/element/src/types.ts:374-380 |
| FreeDraw creation | packages/excalidraw/components/App.tsx:8230-8247 |
| Localization | packages/excalidraw/locales/en.json:28,72-73,77 |