You can use series of bool properties (as elements of a collection) which allows to have an active item and also trigger an event by using the update
function of each BoolProperty
.
Each BoolProperty
can be displayed as an icon and the icon itself can be generated on the fly by adding a new preview collection and then assigning a pixel array to each new icon added: https://docs.blender.org/api/current/bpy.types.ImagePreview.html

Example of a custom color palette using BoolProperties
. When a color is selected from the list, the color is assigned to base color of the principled shader and the diffuse color of all materials.
import bpy
import bpy.utils.previews
class HelloWorldPanel(bpy.types.Panel):
"""Creates a Panel in the Object properties window"""
bl_label = "Hello World Panel"
bl_idname = "OBJECT_PT_hello"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "object"
def draw(self, context):
layout = self.layout
scn = context.scene
col = layout.column(align=True)
row = col.row(align=True)
active_item = None
for idx, item in enumerate(scn.color_collection, start=1):
row.prop(item, "active", icon_value=item.icon, icon_only=True)
if item.active == True:
active_item = item
if idx % 11 == 0:
row = col.row(align=True)
if active_item:
row = layout.row()
r, g, b, a = active_item.color
row.label(text=f"Active item: {r:.2f} {g:.2f} {b:.2f} {a:.2f}")
def update_callback(self, context):
if self.active:
for i in self.id_data.color_collection:
if i.name != self.name:
i.active = False
# set the diffuse and base color for all materials
for slot in context.object.material_slots:
material = slot.material
material.diffuse_color = self.color
if material.use_nodes:
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
node.inputs['Base Color'].default_value = self.color
class ColorCollection(bpy.types.PropertyGroup):
# name: bpy.props.StringProperty
active: bpy.props.BoolProperty(default=False, update=update_callback)
icon: bpy.props.IntProperty()
color: bpy.props.FloatVectorProperty(
name = "Color",
subtype = "COLOR",
default = (1.0,1.0,1.0,1.0),
size = 4)
# We can store multiple preview collections here,
# however in this example we only store "main"
preview_collections = {}
color_palette = [
(0.46, 0.41, 0.62, 1),
(0.67, 0.18, 0.34, 1),
(0.64, 0.10, 0.91, 1),
(0.66, 0.09, 0.01, 1),
(0.86, 0.90, 0.90, 1),
(0.95, 0.27, 0.83, 1),
(0.53, 0.08, 0.78, 1),
(0.47, 0.27, 0.61, 1)]
def register():
# register the classes
bpy.utils.register_class(HelloWorldPanel)
bpy.utils.register_class(ColorCollection)
bpy.types.Scene.color_collection = bpy.props.CollectionProperty(type=ColorCollection)
# clear the collection
if hasattr(bpy.context.scene, "color_collection"):
bpy.context.scene.color_collection.clear()
# generate colors and icons
pcoll = bpy.utils.previews.new()
size = 32, 32
for i, color in enumerate(color_palette):
color_name = f"Color{i}"
pixels = [*color] * size[0] * size[1]
icon = pcoll.new(color_name) # name has to be unique!
icon.icon_size = size
icon.is_icon_custom = True
icon.icon_pixels_float = pixels
# add the item to the collection
color_item = bpy.context.scene.color_collection.add()
color_item.name = color_name
color_item.color = color
color_item.icon = pcoll[color_name].icon_id
preview_collections["main"] = pcoll
def unregister():
for pcoll in preview_collections.values():
bpy.utils.previews.remove(pcoll)
preview_collections.clear()
bpy.utils.unregister_class(ColorCollection)
bpy.utils.unregister_class(HelloWorldPanel)
del bpy.types.Scene.color_collection
if __name__ == "__main__":
register()
If you prefer to use an operator instead of the update function, see one of my previous answers to: How to display a fixed list of RGB values in a panel and when a color is clicked call an operator