Building Custom UI5 Controls: A Comprehensive Tutorial
While SAP UI5 provides an extensive library of controls, every enterprise application eventually needs something custom — a unique widget, a specialized input, or a complex visualization that doesn't exist out of the box.
Building custom controls transforms you from a UI5 consumer to a UI5 creator, giving you the power to extend the framework with reusable, enterprise-grade components.
This comprehensive guide will teach you how to create custom controls from scratch, extend existing ones, and apply best practices for maintainability and performance.
Understanding UI5 Control Architecture
The Control Hierarchy
sap.ui.base.Object
└── sap.ui.base.EventProvider
└── sap.ui.base.ManagedObject
└── sap.ui.core.Element
└── sap.ui.core.Control
└── Your Custom ControlControl Lifecycle Methods
init()– Initialization, create internal controlsonBeforeRendering()– Before DOM updateonAfterRendering()– After DOM update, DOM manipulationexit()– Cleanup, destroy internal controls
Approach 1: Extending Existing Controls
Example: Custom Button with Counter
sap.ui.define([
"sap/m/Button",
"sap/m/ButtonRenderer"
], function(Button, ButtonRenderer) {
"use strict";
return Button.extend("com.mycompany.controls.CounterButton", {
metadata: {
properties: {
count: { type: "int", defaultValue: 0 }
},
events: {
countChanged: {
parameters: {
newCount: { type: "int" }
}
}
}
},
renderer: ButtonRenderer,
init: function() {
Button.prototype.init.apply(this, arguments);
this.attachPress(this._onPress, this);
},
_onPress: function() {
var iNewCount = this.getCount() + 1;
this.setCount(iNewCount);
this.setText("Clicked " + iNewCount + " times");
this.fireCountChanged({ newCount: iNewCount });
},
onAfterRendering: function() {
Button.prototype.onAfterRendering.apply(this, arguments);
// Additional DOM manipulations if needed
}
});
});Usage in View
<mvc:View
xmlns:mvc="sap.ui.core.mvc"
xmlns:custom="com.mycompany.controls">
<custom:CounterButton
text="Click me"
count="0"
countChanged=".onCountChanged"/>
</mvc:View>Approach 2: Creating Controls from Scratch
Example: Star Rating Control
sap.ui.define([
"sap/ui/core/Control",
"sap/m/RatingIndicator"
], function(Control, RatingIndicator) {
"use strict";
return Control.extend("com.mycompany.controls.StarRating", {
metadata: {
properties: {
rating: { type: "float", defaultValue: 0 },
maxRating: { type: "int", defaultValue: 5 },
editable: { type: "boolean", defaultValue: true },
size: { type: "string", defaultValue: "24px" }
},
aggregations: {},
events: {
change: {
parameters: {
rating: { type: "float" }
}
}
}
},
init: function() {
this._aStars = [];
},
renderer: function(oRm, oControl) {
var iMaxRating = oControl.getMaxRating();
var fRating = oControl.getRating();
var sSize = oControl.getSize();
var bEditable = oControl.getEditable();
oRm.openStart("div", oControl);
oRm.class("customStarRating");
oRm.style("font-size", sSize);
oRm.openEnd();
for (var i = 1; i <= iMaxRating; i++) {
oRm.openStart("span");
oRm.class("star");
oRm.attr("data-rating", i);
if (bEditable) {
oRm.style("cursor", "pointer");
}
// Determine star state: full, half, or empty
if (i <= Math.floor(fRating)) {
oRm.class("star-full");
} else if (i === Math.ceil(fRating) && fRating % 1 !== 0) {
oRm.class("star-half");
} else {
oRm.class("star-empty");
}
oRm.openEnd();
// Unicode star character
oRm.text(i <= fRating ? "★" : "☆");
oRm.close("span");
}
oRm.close("div");
},
onAfterRendering: function() {
if (this.getEditable()) {
var oDomRef = this.getDomRef();
var aStars = oDomRef.querySelectorAll(".star");
aStars.forEach(function(oStar) {
oStar.addEventListener("click", this._onStarClick.bind(this));
oStar.addEventListener("mouseenter", this._onStarHover.bind(this));
}.bind(this));
oDomRef.addEventListener("mouseleave", this._onMouseLeave.bind(this));
}
},
_onStarClick: function(oEvent) {
var iRating = parseInt(oEvent.target.getAttribute("data-rating"));
this.setRating(iRating);
this.fireChange({ rating: iRating });
},
_onStarHover: function(oEvent) {
var iHoverRating = parseInt(oEvent.target.getAttribute("data-rating"));
this._highlightStars(iHoverRating);
},
_onMouseLeave: function() {
this._highlightStars(this.getRating());
},
_highlightStars: function(iRating) {
var oDomRef = this.getDomRef();
if (!oDomRef) return;
var aStars = oDomRef.querySelectorAll(".star");
aStars.forEach(function(oStar, index) {
if (index < iRating) {
oStar.classList.add("star-highlight");
} else {
oStar.classList.remove("star-highlight");
}
});
},
exit: function() {
// Cleanup
this._aStars = null;
}
});
});CSS for Star Rating
.customStarRating {
display: inline-flex;
gap: 4px;
}
.star {
transition: all 0.2s ease;
}
.star-full {
color: #f5a623;
}
.star-half {
background: linear-gradient(90deg, #f5a623 50%, #d8d8d8 50%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.star-empty {
color: #d8d8d8;
}
.star-highlight {
color: #f5a623;
transform: scale(1.1);
}Approach 3: Composite Controls
Example: Search Field with Auto-Complete
sap.ui.define([
"sap/ui/core/Control",
"sap/m/Input",
"sap/m/List",
"sap/m/StandardListItem",
"sap/m/Popover"
], function(Control, Input, List, StandardListItem, Popover) {
"use strict";
return Control.extend("com.mycompany.controls.AutoCompleteSearch", {
metadata: {
properties: {
value: { type: "string", defaultValue: "" },
placeholder: { type: "string", defaultValue: "Search..." },
suggestions: { type: "object[]", defaultValue: [] }
},
aggregations: {
_input: { type: "sap.m.Input", multiple: false, visibility: "hidden" },
_popover: { type: "sap.m.Popover", multiple: false, visibility: "hidden" }
},
events: {
search: {
parameters: {
query: { type: "string" }
}
},
suggestionSelected: {
parameters: {
selectedItem: { type: "object" }
}
}
}
},
init: function() {
// Create internal input field
var oInput = new Input({
placeholder: "{/placeholder}",
value: "{/value}",
liveChange: this._onLiveChange.bind(this),
submit: this._onSubmit.bind(this)
});
this.setAggregation("_input", oInput);
// Create popover with list
var oList = new List({
mode: "SingleSelectMaster",
selectionChange: this._onSuggestionSelect.bind(this)
});
var oPopover = new Popover({
showHeader: false,
placement: "Bottom",
content: [oList]
});
this.setAggregation("_popover", oPopover);
},
renderer: function(oRm, oControl) {
oRm.openStart("div", oControl);
oRm.class("autoCompleteSearch");
oRm.openEnd();
oRm.renderControl(oControl.getAggregation("_input"));
oRm.close("div");
},
_onLiveChange: function(oEvent) {
var sValue = oEvent.getParameter("value");
this.setProperty("value", sValue, true); // suppress rerendering
if (sValue.length >= 2) {
this._showSuggestions(sValue);
} else {
this._closeSuggestions();
}
},
_showSuggestions: function(sQuery) {
var aSuggestions = this.getSuggestions();
var aFiltered = aSuggestions.filter(function(oItem) {
return oItem.title.toLowerCase().indexOf(sQuery.toLowerCase()) !== -1;
});
var oPopover = this.getAggregation("_popover");
var oList = oPopover.getContent()[0];
oList.destroyItems();
aFiltered.forEach(function(oItem) {
oList.addItem(new StandardListItem({
title: oItem.title,
description: oItem.description,
customData: [new sap.ui.core.CustomData({ key: "data", value: oItem })]
}));
});
if (aFiltered.length > 0) {
var oInput = this.getAggregation("_input");
if (!oPopover.isOpen()) {
oPopover.openBy(oInput);
}
}
},
_closeSuggestions: function() {
var oPopover = this.getAggregation("_popover");
if (oPopover.isOpen()) {
oPopover.close();
}
},
_onSuggestionSelect: function(oEvent) {
var oSelectedItem = oEvent.getParameter("listItem");
var oData = oSelectedItem.data("data");
this.setValue(oData.title);
this.fireSuggestionSelected({ selectedItem: oData });
this._closeSuggestions();
},
_onSubmit: function(oEvent) {
var sValue = oEvent.getParameter("value");
this.fireSearch({ query: sValue });
this._closeSuggestions();
},
exit: function() {
var oPopover = this.getAggregation("_popover");
if (oPopover) {
oPopover.destroy();
}
}
});
});Advanced Control Features
Data Binding Support
metadata: {
properties: {
items: { type: "object[]", bindable: true }
}
}
// In controller
var oModel = new JSONModel({ items: [...] });
this.getView().setModel(oModel);
// In view
<custom:MyControl items="{/items}"/>Custom Renderer
// Separate renderer file: MyControlRenderer.js
sap.ui.define([], function() {
"use strict";
var MyControlRenderer = {
apiVersion: 2, // Enable modern rendering
render: function(oRm, oControl) {
oRm.openStart("div", oControl);
oRm.class("myCustomControl");
oRm.openEnd();
// Render content
var aItems = oControl.getItems();
aItems.forEach(function(oItem) {
oRm.openStart("span");
oRm.class("item");
oRm.openEnd();
oRm.text(oItem.text);
oRm.close("span");
});
oRm.close("div");
}
};
return MyControlRenderer;
}, /* bExport= */ true);Semantic Rendering (apiVersion: 2)
renderer: {
apiVersion: 2,
render: function(oRm, oControl) {
// Modern approach - better performance
oRm.openStart("div", oControl);
oRm.attr("role", "button");
oRm.attr("aria-label", oControl.getText());
oRm.class("myControl");
if (oControl.getEnabled()) {
oRm.class("enabled");
}
oRm.openEnd();
oRm.text(oControl.getText());
oRm.close("div");
}
}Control Validation and Error Handling
metadata: {
properties: {
email: {
type: "string",
defaultValue: "",
bindable: true
},
valueState: {
type: "sap.ui.core.ValueState",
defaultValue: "None"
},
valueStateText: {
type: "string",
defaultValue: ""
}
}
},
validateEmail: function() {
var sEmail = this.getEmail();
var oRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
if (!sEmail) {
this.setValueState("None");
this.setValueStateText("");
} else if (oRegex.test(sEmail)) {
this.setValueState("Success");
this.setValueStateText("Valid email");
} else {
this.setValueState("Error");
this.setValueStateText("Invalid email format");
}
}Accessibility (ARIA Support)
renderer: function(oRm, oControl) {
oRm.openStart("div", oControl);
oRm.class("customControl");
// ARIA attributes
oRm.attr("role", "button");
oRm.attr("aria-label", oControl.getText());
oRm.attr("aria-pressed", oControl.getPressed());
oRm.attr("aria-disabled", !oControl.getEnabled());
oRm.attr("tabindex", oControl.getEnabled() ? "0" : "-1");
oRm.openEnd();
oRm.text(oControl.getText());
oRm.close("div");
},
// Keyboard support
onAfterRendering: function() {
this.getDomRef().addEventListener("keydown", function(oEvent) {
if (oEvent.key === "Enter" || oEvent.key === " ") {
oEvent.preventDefault();
this.firePress();
}
}.bind(this));
}Performance Optimization
Efficient Re-rendering
// Suppress re-rendering when appropriate
this.setProperty("count", iNewCount, true); // true = no re-render
// Invalidate only when necessary
if (sOldValue !== sNewValue) {
this.invalidate();
}
// Use buffering for multiple updates
this.setProperty("prop1", val1, true);
this.setProperty("prop2", val2, true);
this.setProperty("prop3", val3, false); // Triggers single re-renderDOM Caching
onAfterRendering: function() {
// Cache DOM references
this._$container = this.$(); // jQuery wrapper
this._oDomRef = this.getDomRef(); // Native DOM
// Use cached references
this._$container.addClass("active");
this._oDomRef.style.color = "red";
},
exit: function() {
// Clear cached references
this._$container = null;
this._oDomRef = null;
}Testing Custom Controls
QUnit.module("StarRating Control");
QUnit.test("Should instantiate control", function(assert) {
var oControl = new StarRating({
rating: 3,
maxRating: 5
});
assert.ok(oControl, "Control created");
assert.strictEqual(oControl.getRating(), 3, "Rating is 3");
assert.strictEqual(oControl.getMaxRating(), 5, "Max rating is 5");
oControl.destroy();
});
QUnit.test("Should render stars correctly", function(assert) {
var oControl = new StarRating({
rating: 3.5,
maxRating: 5
});
oControl.placeAt("qunit-fixture");
sap.ui.getCore().applyChanges();
var oDomRef = oControl.getDomRef();
var aStars = oDomRef.querySelectorAll(".star");
assert.strictEqual(aStars.length, 5, "5 stars rendered");
assert.ok(aStars[2].classList.contains("star-full"), "3rd star is full");
assert.ok(aStars[3].classList.contains("star-half"), "4th star is half");
oControl.destroy();
});
QUnit.test("Should fire change event", function(assert) {
var done = assert.async();
var oControl = new StarRating({
rating: 2,
editable: true,
change: function(oEvent) {
assert.strictEqual(oEvent.getParameter("rating"), 4, "Rating changed to 4");
done();
}
});
oControl.placeAt("qunit-fixture");
sap.ui.getCore().applyChanges();
// Simulate click
oControl.getDomRef().querySelectorAll(".star")[3].click();
oControl.destroy();
});Library Definition
// library.js
sap.ui.define([
"sap/ui/core/library"
], function() {
"use strict";
sap.ui.getCore().initLibrary({
name: "com.mycompany.controls",
version: "1.0.0",
dependencies: ["sap.ui.core", "sap.m"],
types: [],
interfaces: [],
controls: [
"com.mycompany.controls.StarRating",
"com.mycompany.controls.CounterButton",
"com.mycompany.controls.AutoCompleteSearch"
],
elements: []
});
return com.mycompany.controls;
});Best Practices
✅ DO
- Follow UI5 naming conventions
- Implement proper lifecycle methods
- Support data binding
- Add ARIA attributes for accessibility
- Clean up resources in exit()
- Use semantic rendering (apiVersion: 2)
- Write comprehensive tests
- Document public APIs with JSDoc
- Optimize re-rendering
❌ AVOID
- Direct DOM manipulation without invalidation
- Memory leaks (unbind events, destroy aggregations)
- Breaking UI5 conventions
- Overly complex controls (prefer composition)
- Ignoring accessibility
- Not testing edge cases
Publishing Controls
# Package structure
my-ui5-library/
├── src/
│ └── com/mycompany/controls/
├── test/
├── package.json
├── .npmrc
└── README.md
# package.json
{
"name": "@mycompany/ui5-custom-controls",
"version": "1.0.0",
"description": "Custom UI5 controls library",
"main": "src/com/mycompany/controls/library.js",
"keywords": ["ui5", "sapui5", "openui5", "controls"],
"ui5": {
"dependencies": ["sap.ui.core", "sap.m"]
}
}
# Publish to npm
npm publish --access publicConclusion
Building custom UI5 controls unlocks unlimited possibilities for your applications. From simple extensions to complex composite controls, you now have the knowledge to create reusable, maintainable components.
Key takeaways:
- Extend existing controls for quick wins
- Build from scratch for unique requirements
- Use composite patterns for complex UIs
- Always consider accessibility and performance
- Test thoroughly and document well
Master custom controls, and you'll transform from a UI5 developer to a UI5 architect.
