React component that renders a text input that can take and update a CSS value of a particular type with a default unit
npm install @acusti/css-value-input



CSSValueInput is a React component that renders a specialized text input
for CSS values with intelligent unit handling, increment/decrement
controls, validation, and normalization. Designed with the user experience
of professional design tools like Adobe Illustrator, it automatically
manages units, enforces constraints, and provides intuitive keyboard
interactions.
- Smart Unit Management - Automatically applies appropriate units based
on CSS value type
- Arrow Key Increment/Decrement - Use ↑/↓ keys to adjust values (Shift
for 10x multiplier)
- Automatic Validation - Enforces min/max bounds and CSS value type
constraints
- Value Normalization - Converts inputs to valid CSS values with proper
units
- Escape to Revert - Press Escape to restore the last valid value
- Custom Validators - Support for regex or function-based validation of
non-numeric values
- Flexible Input Types - Supports length, angle, time, percentage, and
integer CSS values
- Design Tool UX - Text selection on focus, enter to confirm, intuitive
interactions
``bash`
npm install @acusti/css-value-inputor
yarn add @acusti/css-value-input
`tsx
import CSSValueInput from '@acusti/css-value-input';
import { useState } from 'react';
function StyleEditor() {
const [width, setWidth] = useState('100px');
const [rotation, setRotation] = useState('0deg');
return (
cssValueType="angle"
value={rotation}
onSubmitValue={setRotation}
step={15}
/>
API Reference
$3
`ts
type Props = {
/**
* Boolean indicating if the user can submit an empty value (i.e. clear
* the value). Defaults to true.
*/
allowEmpty?: boolean; /* Additional CSS class name for styling /
className?: string;
/* Type of CSS value: 'length', 'angle', 'time', 'percent', or 'integer' /
cssValueType?: CSSValueType;
/* Disable the input /
disabled?: boolean;
/**
* Function that receives a value and converts it to its numerical equivalent
* (i.e. '12px' → 12). Defaults to parseFloat().
*/
getValueAsNumber?: (value: string | number) => number;
/* Icon element to display before the input /
icon?: React.ReactNode;
/* Label text displayed above the input /
label?: string;
/* Maximum allowed numeric value /
max?: number;
/* Minimum allowed numeric value /
min?: number;
/* HTML name attribute for forms /
name?: string;
/* Called when input loses focus /
onBlur?: (event: React.FocusEvent) => unknown;
/* Called on each keystroke (before validation) /
onChange?: (event: React.ChangeEvent) => unknown;
/* Called when input gains focus /
onFocus?: (event: React.FocusEvent) => unknown;
/* Called on key press (before built-in key handling) /
onKeyDown?: (event: React.KeyboardEvent) => unknown;
/* Called on key release /
onKeyUp?: (event: React.KeyboardEvent) => unknown;
/**
* Called when the user submits a value (Enter key or blur after change).
* This is your main callback for getting the validated, normalized CSS value.
*/
onSubmitValue: (value: string) => unknown;
/* Placeholder text when input is empty /
placeholder?: string;
/* Step size for arrow key increments (default: 1) /
step?: number;
/* HTML tabindex for focus order /
tabIndex?: number;
/* Tooltip text /
title?: string;
/* Default unit to apply (auto-detected from cssValueType if not provided) /
unit?: string;
/* Custom validator for non-numeric values (RegExp or function) /
validator?: RegExp | ((value: string) => boolean);
/* Current value of the input /
value?: string;
};
`$3
The component supports all CSS value types from
@acusti/css-values:-
length - px, em, rem, %, vh, vw, etc. (default: px)
- angle - deg, rad, grad, turn (default: deg)
- time - s, ms (default: s)
- percent - % (default: %)
- integer - whole numbers only (no unit)Usage Examples
$3
`tsx
import CSSValueInput from '@acusti/css-value-input';
import { useState } from 'react';function PropertyPanel({ selectedElement }) {
const [styles, setStyles] = useState({
width: '100px',
height: '100px',
borderRadius: '0px',
rotation: '0deg',
opacity: '100%',
animationDuration: '0.3s',
});
const updateStyle = (property: string) => (value: string) => {
setStyles((prev) => ({ ...prev, [property]: value }));
// Apply to selected element
if (selectedElement) {
selectedElement.style[property] = value;
}
};
return (
Transform
label="Width"
cssValueType="length"
value={styles.width}
onSubmitValue={updateStyle('width')}
min={0}
icon="📏"
/> label="Height"
cssValueType="length"
value={styles.height}
onSubmitValue={updateStyle('height')}
min={0}
icon="📐"
/>
label="Border Radius"
cssValueType="length"
value={styles.borderRadius}
onSubmitValue={updateStyle('borderRadius')}
min={0}
step={5}
icon="⭕"
/>
label="Rotation"
cssValueType="angle"
value={styles.rotation}
onSubmitValue={updateStyle('rotation')}
step={15}
icon="🔄"
/>
Appearance
label="Opacity"
cssValueType="percent"
value={styles.opacity}
onSubmitValue={updateStyle('opacity')}
min={0}
max={100}
step={5}
icon="👁️"
/> label="Animation Duration"
cssValueType="time"
value={styles.animationDuration}
onSubmitValue={updateStyle('animationDuration')}
min={0}
step={0.1}
icon="⏱️"
/>
);
}
`$3
`tsx
import CSSValueInput from '@acusti/css-value-input';
import { useState } from 'react';function ResponsiveControls() {
const [breakpoints, setBreakpoints] = useState({
mobile: '480px',
tablet: '768px',
desktop: '1024px',
wide: '1440px',
});
const [spacing, setSpacing] = useState({
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
});
const updateBreakpoint = (key: string) => (value: string) => {
setBreakpoints((prev) => ({ ...prev, [key]: value }));
};
const updateSpacing = (key: string) => (value: string) => {
setSpacing((prev) => ({ ...prev, [key]: value }));
};
return (
Breakpoints
{Object.entries(breakpoints).map(([key, value]) => (
key={key}
label={key.charAt(0).toUpperCase() + key.slice(1)}
cssValueType="length"
value={value}
onSubmitValue={updateBreakpoint(key)}
min={200}
max={2560}
step={10}
unit="px"
/>
))}
Spacing Scale
{Object.entries(spacing).map(([key, value]) => (
key={key}
label={key.toUpperCase()}
cssValueType="length"
value={value}
onSubmitValue={updateSpacing(key)}
min={0}
max={100}
step={2}
/>
))}
);
}
`$3
`tsx
import CSSValueInput from '@acusti/css-value-input';
import { useState } from 'react';function KeyframeEditor() {
const [keyframes, setKeyframes] = useState([
{
offset: '0%',
transform: 'translateX(0px) rotate(0deg)',
opacity: '100%',
},
{
offset: '50%',
transform: 'translateX(100px) rotate(180deg)',
opacity: '50%',
},
{
offset: '100%',
transform: 'translateX(0px) rotate(360deg)',
opacity: '100%',
},
]);
const [animationSettings, setAnimationSettings] = useState({
duration: '2s',
delay: '0s',
timingFunction: 'ease-in-out',
iterations: '1',
});
const updateKeyframe = (
index: number,
property: string,
value: string,
) => {
setKeyframes((prev) =>
prev.map((kf, i) =>
i === index ? { ...kf, [property]: value } : kf,
),
);
};
return (
Animation Settings
label="Duration"
cssValueType="time"
value={animationSettings.duration}
onSubmitValue={(value) =>
setAnimationSettings((prev) => ({
...prev,
duration: value,
}))
}
min={0}
step={0.1}
/> label="Delay"
cssValueType="time"
value={animationSettings.delay}
onSubmitValue={(value) =>
setAnimationSettings((prev) => ({
...prev,
delay: value,
}))
}
min={0}
step={0.1}
/>
label="Iterations"
cssValueType="integer"
value={animationSettings.iterations}
onSubmitValue={(value) =>
setAnimationSettings((prev) => ({
...prev,
iterations: value,
}))
}
min={1}
validator={(value) =>
value === 'infinite' || !isNaN(Number(value))
}
/>
Keyframes
{keyframes.map((keyframe, index) => (
Keyframe {index + 1}
label="Offset"
cssValueType="percent"
value={keyframe.offset}
onSubmitValue={(value) =>
updateKeyframe(index, 'offset', value)
}
min={0}
max={100}
step={5}
/> label="Opacity"
cssValueType="percent"
value={keyframe.opacity}
onSubmitValue={(value) =>
updateKeyframe(index, 'opacity', value)
}
min={0}
max={100}
step={10}
/>
))}
);
}
`$3
`tsx
import CSSValueInput from '@acusti/css-value-input';
import { useState } from 'react';function GridLayoutBuilder() {
const [gridSettings, setGridSettings] = useState({
columns: '1fr 1fr 1fr',
rows: 'auto auto',
columnGap: '16px',
rowGap: '16px',
padding: '20px',
});
const [itemSettings, setItemSettings] = useState({
columnStart: '1',
columnEnd: '2',
rowStart: '1',
rowEnd: '2',
});
return (
Grid Container
label="Column Gap"
cssValueType="length"
value={gridSettings.columnGap}
onSubmitValue={(value) =>
setGridSettings((prev) => ({
...prev,
columnGap: value,
}))
}
min={0}
step={4}
/> label="Row Gap"
cssValueType="length"
value={gridSettings.rowGap}
onSubmitValue={(value) =>
setGridSettings((prev) => ({
...prev,
rowGap: value,
}))
}
min={0}
step={4}
/>
label="Padding"
cssValueType="length"
value={gridSettings.padding}
onSubmitValue={(value) =>
setGridSettings((prev) => ({
...prev,
padding: value,
}))
}
min={0}
step={4}
/>
Grid Item Position
label="Column Start"
cssValueType="integer"
value={itemSettings.columnStart}
onSubmitValue={(value) =>
setItemSettings((prev) => ({
...prev,
columnStart: value,
}))
}
min={1}
/> label="Column End"
cssValueType="integer"
value={itemSettings.columnEnd}
onSubmitValue={(value) =>
setItemSettings((prev) => ({
...prev,
columnEnd: value,
}))
}
min={1}
/>
label="Row Start"
cssValueType="integer"
value={itemSettings.rowStart}
onSubmitValue={(value) =>
setItemSettings((prev) => ({
...prev,
rowStart: value,
}))
}
min={1}
/>
label="Row End"
cssValueType="integer"
value={itemSettings.rowEnd}
onSubmitValue={(value) =>
setItemSettings((prev) => ({
...prev,
rowEnd: value,
}))
}
min={1}
/>
style={{
display: 'grid',
gridTemplateColumns: gridSettings.columns,
gridTemplateRows: gridSettings.rows,
columnGap: gridSettings.columnGap,
rowGap: gridSettings.rowGap,
padding: gridSettings.padding,
border: '1px dashed #ccc',
minHeight: '200px',
}}
>
style={{
gridColumnStart: itemSettings.columnStart,
gridColumnEnd: itemSettings.columnEnd,
gridRowStart: itemSettings.rowStart,
gridRowEnd: itemSettings.rowEnd,
backgroundColor: '#e3f2fd',
padding: '8px',
border: '1px solid #2196f3',
}}
>
Grid Item
$3
`tsx
import CSSValueInput from '@acusti/css-value-input';
import { useState } from 'react';function TypographyControls() {
const [typography, setTypography] = useState({
fontSize: '16px',
lineHeight: '1.5',
letterSpacing: '0px',
wordSpacing: '0px',
textIndent: '0px',
});
const updateTypography = (property: string) => (value: string) => {
setTypography((prev) => ({ ...prev, [property]: value }));
};
return (
Typography
label="Font Size"
cssValueType="length"
value={typography.fontSize}
onSubmitValue={updateTypography('fontSize')}
min={8}
max={72}
step={1}
icon="🔤"
/>
label="Line Height"
cssValueType="length"
value={typography.lineHeight}
onSubmitValue={updateTypography('lineHeight')}
min={0.5}
max={3}
step={0.1}
unit="" // Line height can be unitless
validator={(value) => {
// Allow unitless numbers or length values
return /^(\d*\.?\d+)(px|em|rem|%)?$/.test(value);
}}
icon="📏"
/>
label="Letter Spacing"
cssValueType="length"
value={typography.letterSpacing}
onSubmitValue={updateTypography('letterSpacing')}
min={-5}
max={10}
step={0.5}
icon="🔤"
/>
label="Word Spacing"
cssValueType="length"
value={typography.wordSpacing}
onSubmitValue={updateTypography('wordSpacing')}
min={-10}
max={20}
step={1}
icon="📝"
/>
label="Text Indent"
cssValueType="length"
value={typography.textIndent}
onSubmitValue={updateTypography('textIndent')}
min={0}
max={100}
step={5}
icon="⬅️"
/>
Lorem ipsum dolor sit amet, consectetur adipiscing
elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis
nostrud exercitation.
);
}
`$3
`tsx
import CSSValueInput from '@acusti/css-value-input';function CustomValidators() {
// CSS function validator (e.g., calc(), var(), etc.)
const cssFunctionValidator = (value: string) => {
return (
/^(calc|var|min|max|clamp)\(.*\)$/.test(value) ||
!isNaN(parseFloat(value))
);
};
// Color hex validator
const hexColorValidator = /^#([0-9A-Fa-f]{3}){1,2}$/;
// CSS keyword validator for display property
const displayKeywordValidator = (value: string) => {
const validKeywords = [
'block',
'inline',
'flex',
'grid',
'none',
'inline-block',
];
return validKeywords.includes(value) || !isNaN(parseFloat(value));
};
return (
label="Width (supports calc)"
cssValueType="length"
onSubmitValue={(value) => console.log('Width:', value)}
validator={cssFunctionValidator}
placeholder="100px or calc(50% - 10px)"
/> label="Border Color"
cssValueType="length" // We'll override the unit behavior
onSubmitValue={(value) => console.log('Color:', value)}
validator={hexColorValidator}
unit="" // No default unit
placeholder="#ff0000"
/>
label="Z-Index"
cssValueType="integer"
onSubmitValue={(value) => console.log('Z-Index:', value)}
min={-999}
max={999}
step={1}
validator={(value) =>
value === 'auto' || !isNaN(parseInt(value))
}
/>
);
}
`Keyboard Interactions
$3
- ↑/↓ - Increment/decrement by step amount
- Shift + ↑/↓ - Increment/decrement by step × 10
- Works with all numeric CSS value types
$3
- Enter - Submit value and blur input
- Escape - Revert to last submitted value and blur
- Tab - Submit value and move to next input
$3
- Auto-complete units - Typing "100" becomes "100px" for length inputs
- Unit preservation - Keeps the unit from the previous value when
possible
- Range enforcement - Automatically clamps values to min/max bounds
- Type coercion - Converts integers when cssValueType="integer"
Styling
The component uses CSS classes with the prefix
cssvalueinput:`css
.cssvalueinput {
/ Main container styles /
}.cssvalueinput-icon {
/ Icon container styles /
}
.cssvalueinput-label {
/ Label container styles /
}
.cssvalueinput-label-text {
/ Label text styles /
}
.cssvalueinput-value {
/ Input wrapper styles /
}
.cssvalueinput.disabled {
/ Disabled state styles /
}
`$3
`css
.cssvalueinput {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}.cssvalueinput-label-text {
font-size: 12px;
font-weight: 600;
color: #333;
margin: 0;
}
.cssvalueinput-icon {
font-size: 16px;
margin-right: 8px;
}
.cssvalueinput input {
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: monospace;
text-align: center;
}
.cssvalueinput input:focus {
outline: 2px solid #007bff;
border-color: transparent;
}
.cssvalueinput.disabled {
opacity: 0.6;
pointer-events: none;
}
`Integration with CSS-in-JS
`tsx
import CSSValueInput from '@acusti/css-value-input';
import styled from 'styled-components';const StyledBox = styled.div<{
width: string;
height: string;
rotation: string;
}>
;function StyledComponentEditor() {
const [boxStyles, setBoxStyles] = useState({
width: '200px',
height: '200px',
rotation: '0deg',
});
return (
label="Width"
cssValueType="length"
value={boxStyles.width}
onSubmitValue={(value) =>
setBoxStyles((prev) => ({ ...prev, width: value }))
}
/> label="Height"
cssValueType="length"
value={boxStyles.height}
onSubmitValue={(value) =>
setBoxStyles((prev) => ({
...prev,
height: value,
}))
}
/>
label="Rotation"
cssValueType="angle"
value={boxStyles.rotation}
onSubmitValue={(value) =>
setBoxStyles((prev) => ({
...prev,
rotation: value,
}))
}
step={15}
/>
Styled Component
);
}
``- Label Association - Proper label/input relationships for screen
readers
- Keyboard Navigation - Full keyboard control without mouse dependency
- Focus Management - Clear focus indicators and logical tab order
- Value Announcements - Screen readers announce value changes
- Error Handling - Invalid values are reverted with visual feedback
- Modern Browsers - Chrome, Firefox, Safari, Edge (latest)
- Mobile Support - Touch-friendly with virtual keyboard support
- SSR Compatible - Works with Next.js, React Router, etc.
- Design Tools - Property panels, style editors, layout builders
- CSS Generators - Live CSS property editors
- Animation Tools - Keyframe editors, timing controls
- Theme Builders - Design system value editors
- Form Builders - CSS-aware form inputs
- Component Libraries - Styleable component property editors
See the
Storybook documentation and examples
for interactive demonstrations of all CSS value input features and
configurations.