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