diff --git a/config.go b/config.go
index 642f113..1c1b98d 100644
--- a/config.go
+++ b/config.go
@@ -14,6 +14,64 @@ type WakeOnLanDevice struct {
 	MacAddress string `json:"macAddress"`
 }
 
+// Constants for keyboard macro limits
+const (
+	MaxMacrosPerDevice = 25
+	MaxStepsPerMacro   = 10
+	MaxKeysPerStep     = 10
+	MinStepDelay       = 50
+	MaxStepDelay       = 2000
+)
+
+type KeyboardMacroStep struct {
+	Keys      []string `json:"keys"`
+	Modifiers []string `json:"modifiers"`
+	Delay     int      `json:"delay"`
+}
+
+func (s *KeyboardMacroStep) Validate() error {
+	if len(s.Keys) > MaxKeysPerStep {
+		return fmt.Errorf("too many keys in step (max %d)", MaxKeysPerStep)
+	}
+
+	if s.Delay < MinStepDelay {
+		s.Delay = MinStepDelay
+	} else if s.Delay > MaxStepDelay {
+		s.Delay = MaxStepDelay
+	}
+
+	return nil
+}
+
+type KeyboardMacro struct {
+	ID        string              `json:"id"`
+	Name      string              `json:"name"`
+	Steps     []KeyboardMacroStep `json:"steps"`
+	SortOrder int                 `json:"sortOrder,omitempty"`
+}
+
+func (m *KeyboardMacro) Validate() error {
+	if m.Name == "" {
+		return fmt.Errorf("macro name cannot be empty")
+	}
+
+	if len(m.Steps) == 0 {
+		return fmt.Errorf("macro must have at least one step")
+	}
+
+	if len(m.Steps) > MaxStepsPerMacro {
+		return fmt.Errorf("too many steps in macro (max %d)", MaxStepsPerMacro)
+	}
+
+	for i := range m.Steps {
+		if err := m.Steps[i].Validate(); err != nil {
+			return fmt.Errorf("invalid step %d: %w", i+1, err)
+		}
+	}
+
+	return nil
+}
+
 type Config struct {
 	CloudURL             string             `json:"cloud_url"`
 	CloudAppURL          string             `json:"cloud_app_url"`
@@ -26,6 +84,7 @@ type Config struct {
 	LocalAuthToken       string             `json:"local_auth_token"`
 	LocalAuthMode        string             `json:"localAuthMode"` //TODO: fix it with migration
 	WakeOnLanDevices     []WakeOnLanDevice  `json:"wake_on_lan_devices"`
+	KeyboardMacros       []KeyboardMacro    `json:"keyboard_macros"`
 	EdidString           string             `json:"hdmi_edid_string"`
 	ActiveExtension      string             `json:"active_extension"`
 	DisplayMaxBrightness int                `json:"display_max_brightness"`
@@ -43,6 +102,7 @@ var defaultConfig = &Config{
 	CloudAppURL:          "https://app.jetkvm.com",
 	AutoUpdateEnabled:    true, // Set a default value
 	ActiveExtension:      "",
+	KeyboardMacros:       []KeyboardMacro{},
 	DisplayMaxBrightness: 64,
 	DisplayDimAfterSec:   120,  // 2 minutes
 	DisplayOffAfterSec:   1800, // 30 minutes
diff --git a/jsonrpc.go b/jsonrpc.go
index 9ce1f1b..e5deb49 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -797,6 +797,99 @@ func rpcSetScrollSensitivity(sensitivity string) error {
 	return nil
 }
 
+func getKeyboardMacros() (interface{}, error) {
+	macros := make([]KeyboardMacro, len(config.KeyboardMacros))
+	copy(macros, config.KeyboardMacros)
+
+	return macros, nil
+}
+
+type KeyboardMacrosParams struct {
+	Macros []interface{} `json:"macros"`
+}
+
+func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
+	if params.Macros == nil {
+		return nil, fmt.Errorf("missing or invalid macros parameter")
+	}
+
+	newMacros := make([]KeyboardMacro, 0, len(params.Macros))
+
+	for i, item := range params.Macros {
+		macroMap, ok := item.(map[string]interface{})
+		if !ok {
+			return nil, fmt.Errorf("invalid macro at index %d", i)
+		}
+
+		id, _ := macroMap["id"].(string)
+		if id == "" {
+			id = fmt.Sprintf("macro-%d", time.Now().UnixNano())
+		}
+
+		name, _ := macroMap["name"].(string)
+
+		sortOrder := i + 1
+		if sortOrderFloat, ok := macroMap["sortOrder"].(float64); ok {
+			sortOrder = int(sortOrderFloat)
+		}
+
+		steps := []KeyboardMacroStep{}
+		if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
+			for _, stepItem := range stepsArray {
+				stepMap, ok := stepItem.(map[string]interface{})
+				if !ok {
+					continue
+				}
+
+				step := KeyboardMacroStep{}
+
+				if keysArray, ok := stepMap["keys"].([]interface{}); ok {
+					for _, k := range keysArray {
+						if keyStr, ok := k.(string); ok {
+							step.Keys = append(step.Keys, keyStr)
+						}
+					}
+				}
+
+				if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
+					for _, m := range modsArray {
+						if modStr, ok := m.(string); ok {
+							step.Modifiers = append(step.Modifiers, modStr)
+						}
+					}
+				}
+
+				if delay, ok := stepMap["delay"].(float64); ok {
+					step.Delay = int(delay)
+				}
+
+				steps = append(steps, step)
+			}
+		}
+
+		macro := KeyboardMacro{
+			ID:        id,
+			Name:      name,
+			Steps:     steps,
+			SortOrder: sortOrder,
+		}
+
+		if err := macro.Validate(); err != nil {
+			return nil, fmt.Errorf("invalid macro at index %d: %w", i, err)
+		}
+
+		newMacros = append(newMacros, macro)
+	}
+
+	config.KeyboardMacros = newMacros
+
+	if err := SaveConfig(); err != nil {
+		return nil, err
+	}
+
+	return nil, nil
+}
+
 var rpcHandlers = map[string]RPCHandler{
 	"ping":                   {Func: rpcPing},
 	"getDeviceID":            {Func: rpcGetDeviceID},
@@ -862,4 +955,6 @@ var rpcHandlers = map[string]RPCHandler{
 	"setCloudUrl":            {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
 	"getScrollSensitivity":   {Func: rpcGetScrollSensitivity},
 	"setScrollSensitivity":   {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
+	"getKeyboardMacros":      {Func: getKeyboardMacros},
+	"setKeyboardMacros":      {Func: setKeyboardMacros, Params: []string{"params"}},
 }
diff --git a/ui/src/components/Checkbox.tsx b/ui/src/components/Checkbox.tsx
index 261a425..e3237b1 100644
--- a/ui/src/components/Checkbox.tsx
+++ b/ui/src/components/Checkbox.tsx
@@ -37,11 +37,11 @@ type CheckBoxProps = {
 } & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
 
 const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
-  { size = "MD", ...props },
+  { size = "MD", className, ...props },
   ref,
 ) {
   const classes = checkboxVariants({ size });
-  return <input ref={ref} {...props} type="checkbox" className={classes} />;
+  return <input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />;
 });
 Checkbox.displayName = "Checkbox";
 
diff --git a/ui/src/components/Combobox.tsx b/ui/src/components/Combobox.tsx
new file mode 100644
index 0000000..8055043
--- /dev/null
+++ b/ui/src/components/Combobox.tsx
@@ -0,0 +1,119 @@
+import { useRef } from "react";
+import clsx from "clsx";
+import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+import { cva } from "@/cva.config";
+import Card from "./Card";
+
+export interface ComboboxOption {
+  value: string;
+  label: string;
+}
+
+const sizes = {
+  XS: "h-[24.5px] pl-3 pr-8 text-xs",
+  SM: "h-[32px] pl-3 pr-8 text-[13px]",
+  MD: "h-[40px] pl-4 pr-10 text-sm",
+  LG: "h-[48px] pl-4 pr-10 px-5 text-base",
+} as const;
+
+const comboboxVariants = cva({
+  variants: { size: sizes },
+});
+
+type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
+
+interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
+  displayValue: (option: ComboboxOption) => string;
+  onInputChange: (option: string) => void;
+  options: () => ComboboxOption[];
+  placeholder?: string;
+  emptyMessage?: string;
+  size?: keyof typeof sizes;
+  disabledMessage?: string;
+}
+
+export function Combobox({
+  onInputChange,
+  displayValue,
+  options,
+  disabled = false,
+  placeholder = "Search...",
+  emptyMessage = "No results found",
+  size = "MD",
+  onChange,
+  disabledMessage = "Input disabled",
+  ...otherProps
+}: ComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const classes = comboboxVariants({ size });
+
+  return (
+    <HeadlessCombobox 
+      onChange={onChange}
+      {...otherProps}
+    >
+      {() => (
+        <>
+          <Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
+            <ComboboxInput
+            ref={inputRef}
+            className={clsx(
+              classes,
+              
+              // General styling
+              "block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
+              
+              // Hover
+              "hover:bg-blue-50/80 active:bg-blue-100/60",
+              
+              // Dark mode
+              "dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
+              
+              // Focus
+              "focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
+              
+              // Disabled
+              disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
+            )}
+            placeholder={disabled ? disabledMessage : placeholder}
+            displayValue={displayValue}
+            onChange={(event) => onInputChange(event.target.value)}
+            disabled={disabled}
+            />
+          </Card>
+          
+          {options().length > 0 && (
+            <ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
+              {options().map((option) => (
+                <ComboboxOption 
+                key={option.value} 
+                value={option}
+                className={clsx(
+                  // General styling
+                  "cursor-default select-none py-2 px-4",
+                  
+                  // Hover and active states
+                  "hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
+                  
+                  // Dark mode
+                  "dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
+                )}
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+            </ComboboxOptions>
+          )}
+          
+          {options().length === 0 && inputRef.current?.value && (
+            <div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700">
+              <div className="text-slate-500 dark:text-slate-400">
+                {emptyMessage}
+              </div>
+            </div>
+          )}
+        </>
+      )}
+    </HeadlessCombobox>
+  );
+}
\ No newline at end of file
diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx
new file mode 100644
index 0000000..57391e2
--- /dev/null
+++ b/ui/src/components/ConfirmDialog.tsx
@@ -0,0 +1,106 @@
+import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
+import { cx } from "@/cva.config";
+import { Button } from "@/components/Button";
+import Modal from "@/components/Modal";
+
+type Variant = "danger" | "success" | "warning" | "info";
+
+interface ConfirmDialogProps {
+  open: boolean;
+  onClose: () => void;
+  title: string;
+  description: string;
+  variant?: Variant;
+  confirmText?: string;
+  cancelText?: string | null;
+  onConfirm: () => void;
+  isConfirming?: boolean;
+}
+
+const variantConfig = {
+  danger: {
+    icon: ExclamationTriangleIcon,
+    iconClass: "text-red-600",
+    iconBgClass: "bg-red-100",
+    buttonTheme: "danger",
+  },
+  success: {
+    icon: CheckCircleIcon,
+    iconClass: "text-green-600",
+    iconBgClass: "bg-green-100",
+    buttonTheme: "primary",
+  },
+  warning: {
+    icon: ExclamationTriangleIcon,
+    iconClass: "text-yellow-600",
+    iconBgClass: "bg-yellow-100",
+    buttonTheme: "lightDanger",
+  },
+  info: {
+    icon: InformationCircleIcon,
+    iconClass: "text-blue-600",
+    iconBgClass: "bg-blue-100",
+    buttonTheme: "primary",
+  },
+} as Record<Variant, {
+    icon: React.ElementType;
+    iconClass: string;
+    iconBgClass: string;
+    buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
+}>;
+
+export function ConfirmDialog({
+  open,
+  onClose,
+  title,
+  description,
+  variant = "info",
+  confirmText = "Confirm",
+  cancelText = "Cancel",
+  onConfirm,
+  isConfirming = false,
+}: ConfirmDialogProps) {
+  const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
+
+  return (
+    <Modal open={open} onClose={onClose}>
+      <div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
+        <div className="relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800 pointer-events-auto">
+          <div className="space-y-4">
+            <div className="sm:flex sm:items-start">
+              <div className={cx("mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10", iconBgClass)}>
+                <Icon aria-hidden="true" className={cx("size-6", iconClass)} />
+              </div>
+              <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
+                <h2 className="text-lg font-bold leading-tight text-black dark:text-white">
+                  {title}
+                </h2>
+                <div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
+                  {description}
+                </div>
+              </div>
+            </div>
+
+            <div className="flex justify-end gap-x-2">
+              {cancelText && (
+                <Button
+                  size="SM"
+                  theme="blank"
+                  text={cancelText}
+                  onClick={onClose}
+                />
+              )}
+              <Button
+                size="SM"
+                theme={buttonTheme}
+                text={isConfirming ? `${confirmText}...` : confirmText}
+                onClick={onConfirm}
+                disabled={isConfirming}
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </Modal>
+  );
+} 
\ No newline at end of file
diff --git a/ui/src/components/FieldLabel.tsx b/ui/src/components/FieldLabel.tsx
index 42e6ede..f9065a1 100644
--- a/ui/src/components/FieldLabel.tsx
+++ b/ui/src/components/FieldLabel.tsx
@@ -49,4 +49,4 @@ export default function FieldLabel({
   } else {
     return <></>;
   }
-}
+}
\ No newline at end of file
diff --git a/ui/src/components/MacroBar.tsx b/ui/src/components/MacroBar.tsx
new file mode 100644
index 0000000..066c21f
--- /dev/null
+++ b/ui/src/components/MacroBar.tsx
@@ -0,0 +1,48 @@
+import { useEffect } from "react";
+import { LuCommand } from "react-icons/lu";
+
+import { Button } from "@components/Button";
+import Container from "@components/Container";
+import { useMacrosStore } from "@/hooks/stores";
+import useKeyboard from "@/hooks/useKeyboard";
+import { useJsonRpc } from "@/hooks/useJsonRpc";
+
+export default function MacroBar() {
+  const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
+  const { executeMacro } = useKeyboard();
+  const [send] = useJsonRpc();
+
+  useEffect(() => {
+    setSendFn(send);
+    
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+
+  if (macros.length === 0) {
+    return null;
+  }
+
+  return (
+    <Container className="bg-white dark:bg-slate-900 border-b border-b-slate-800/20 dark:border-b-slate-300/20">
+      <div className="flex items-center gap-x-2 py-1.5">
+        <div className="absolute -ml-5">
+          <LuCommand className="h-4 w-4 text-slate-500" />
+        </div>
+        <div className="flex flex-wrap gap-2">
+          {macros.map(macro => (
+            <Button
+              key={macro.id}
+              aria-label={macro.name}
+              size="XS"
+              theme="light"
+              text={macro.name}
+              onClick={() => executeMacro(macro.steps)}
+            />
+          ))}
+        </div>
+      </div>
+    </Container>
+  );
+} 
\ No newline at end of file
diff --git a/ui/src/components/MacroForm.tsx b/ui/src/components/MacroForm.tsx
new file mode 100644
index 0000000..135817c
--- /dev/null
+++ b/ui/src/components/MacroForm.tsx
@@ -0,0 +1,271 @@
+import { useState } from "react";
+
+import { LuPlus } from "react-icons/lu";
+
+import { KeySequence } from "@/hooks/stores";
+import { Button } from "@/components/Button";
+import { InputFieldWithLabel, FieldError } from "@/components/InputField";
+import Fieldset from "@/components/Fieldset";
+import { MacroStepCard } from "@/components/MacroStepCard";
+import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
+import FieldLabel from "@/components/FieldLabel";
+
+interface ValidationErrors {
+  name?: string;
+  steps?: Record<number, {
+    keys?: string;
+    modifiers?: string;
+    delay?: string;
+  }>;
+}
+
+interface MacroFormProps {
+  initialData: Partial<KeySequence>;
+  onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
+  onCancel: () => void;
+  isSubmitting?: boolean;
+  submitText?: string;
+}
+
+export function MacroForm({
+  initialData,
+  onSubmit,
+  onCancel,
+  isSubmitting = false,
+  submitText = "Save Macro",
+}: MacroFormProps) {
+  const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+
+  const showTemporaryError = (message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  };
+
+  const validateForm = (): boolean => {
+    const newErrors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      newErrors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      newErrors.name = "Name must be less than 50 characters";
+    }
+  
+    if (!macro.steps?.length) {
+      newErrors.steps = { 0: { keys: "At least one step is required" } };
+    } else {
+      const hasKeyOrModifier = macro.steps.some(step => 
+        (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+      );
+
+      if (!hasKeyOrModifier) {
+        newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      }
+    }
+
+    setErrors(newErrors);
+    return Object.keys(newErrors).length === 0;
+  };
+
+  const handleSubmit = async () => {
+    if (!validateForm()) {
+      showTemporaryError("Please fix the validation errors");
+      return;
+    }
+
+    try {
+      await onSubmit(macro);
+    } catch (error) {
+      if (error instanceof Error) {
+        showTemporaryError(error.message);
+      } else {
+        showTemporaryError("An error occurred while saving");
+      }
+    }
+  };
+
+  const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
+    const newSteps = [...(macro.steps || [])];
+    if (!newSteps[stepIndex]) return;
+
+    if (option.keys) {
+      newSteps[stepIndex].keys = option.keys;
+    } else if (option.value) {
+      if (!newSteps[stepIndex].keys) {
+        newSteps[stepIndex].keys = [];
+      }
+      const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
+      if (keysArray.length >= MAX_KEYS_PER_STEP) {
+        showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+        return;
+      }
+      newSteps[stepIndex].keys = [...keysArray, option.value];
+    }
+    setMacro({ ...macro, steps: newSteps });
+    
+    if (errors.steps?.[stepIndex]?.keys) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].modifiers = modifiers;
+    setMacro({ ...macro, steps: newSteps });
+    
+    // Clear step errors when modifiers are added
+    if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].delay = delay;
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
+    const newSteps = [...(macro.steps || [])];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+
+  return (
+    <>
+      <div className="space-y-4">
+        <Fieldset>
+          <InputFieldWithLabel
+            type="text"
+            label="Macro Name"
+            placeholder="Macro Name"
+            value={macro.name}
+            error={errors.name}
+            onChange={e => {
+              setMacro(prev => ({ ...prev, name: e.target.value }));
+              if (errors.name) {
+                const newErrors = { ...errors };
+                delete newErrors.name;
+                setErrors(newErrors);
+              }
+            }}
+          />
+        </Fieldset>
+
+        <div>
+          <div className="flex items-center justify-between text-sm">
+            <div className="flex items-center gap-1">
+              <FieldLabel label="Steps" description={`Keys/modifiers executed in sequence with a delay between each step.`} />
+            </div>
+            <span className="text-slate-500 dark:text-slate-400">
+              {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+            </span>
+          </div>
+          {errors.steps && errors.steps[0]?.keys && (
+            <div className="mt-2">
+              <FieldError error={errors.steps[0].keys} />
+            </div>
+          )}
+          <Fieldset>
+            <div className="mt-2 space-y-4">
+              {(macro.steps || []).map((step, stepIndex) => (
+                <MacroStepCard
+                  key={stepIndex}
+                  step={step}
+                  stepIndex={stepIndex}
+                  onDelete={macro.steps && macro.steps.length > 1 ? () => {
+                    const newSteps = [...(macro.steps || [])];
+                    newSteps.splice(stepIndex, 1);
+                    setMacro(prev => ({ ...prev, steps: newSteps }));
+                  } : undefined}
+                  onMoveUp={() => handleStepMove(stepIndex, 'up')}
+                  onMoveDown={() => handleStepMove(stepIndex, 'down')}
+                  onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                  onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                  keyQuery={keyQueries[stepIndex] || ''}
+                  onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                  onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                  isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
+                />
+              ))}
+            </div>
+          </Fieldset>
+
+          <div className="mt-4">
+            <Button
+              size="MD"
+              theme="light"
+              fullWidth
+              LeadingIcon={LuPlus}
+              text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
+              onClick={() => {
+                if (isMaxStepsReached) {
+                  showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+                  return;
+                }
+                
+                setMacro(prev => ({
+                  ...prev,
+                  steps: [
+                    ...(prev.steps || []), 
+                    { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+                  ],
+                }));
+                setErrors({});
+              }}
+              disabled={isMaxStepsReached}
+            />
+          </div>
+
+          {errorMessage && (
+            <div className="mt-4">
+              <FieldError error={errorMessage} />
+            </div>
+          )}
+
+          <div className="mt-6 flex items-center gap-x-2">
+            <Button
+              size="SM"
+              theme="primary"
+              text={isSubmitting ? "Saving..." : submitText}
+              onClick={handleSubmit}
+              disabled={isSubmitting}
+            />
+            <Button
+              size="SM"
+              theme="light"
+              text="Cancel"
+              onClick={onCancel}
+            />
+          </div>
+        </div>
+      </div>
+    </>
+  );
+}
\ No newline at end of file
diff --git a/ui/src/components/MacroStepCard.tsx b/ui/src/components/MacroStepCard.tsx
new file mode 100644
index 0000000..8642c28
--- /dev/null
+++ b/ui/src/components/MacroStepCard.tsx
@@ -0,0 +1,235 @@
+import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
+import FieldLabel from "@/components/FieldLabel";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const basePresetDelays = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+const PRESET_DELAYS = basePresetDelays.map(delay => {
+  if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
+    return { ...delay, label: "Default" };
+  }
+  return delay;
+});
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <span className="flex h-6 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+
+        <div className="flex items-center space-x-2">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="light"
+              className="text-red-500 dark:text-red-400"
+              text="Delete"
+              LeadingIcon={LuTrash2}
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <FieldLabel label="Modifiers" />
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-4 pt-1">
+                  {mods.map(option => (
+                    <Button
+                      key={option.value}
+                      size="XS"
+                      theme={ensureArray(step.modifiers).includes(option.value) ? "primary" : "light"}
+                      text={option.label.split(' ')[1] || option.label}
+                      onClick={() => {
+                        const modifiersArray = ensureArray(step.modifiers);
+                        const isSelected = modifiersArray.includes(option.value);
+                        const newModifiers = isSelected
+                          ? modifiersArray.filter(m => m !== option.value)
+                          : [...modifiersArray, option.value];
+                        onModifierChange(newModifiers);
+                      }}
+                    />
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
+          </div>
+          {ensureArray(step.keys) && step.keys.length > 0 && (
+            <div className="flex flex-wrap gap-1 pb-2">
+              {step.keys.map((key, keyIndex) => (
+                <span
+                  key={keyIndex}
+                  className="inline-flex items-center py-0.5 rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
+                >
+                  <span className="px-1">
+                    {keyDisplayMap[key] || key}
+                  </span>
+                  <Button
+                    size="XS"
+                    className=""
+                    theme="blank"
+                    onClick={() => {
+                      const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                      onKeySelect({ value: null, keys: newKeys });
+                    }}
+                    LeadingIcon={LuX}
+                  />
+                </span>
+              ))}
+            </div>
+          )}
+          <div className="relative w-full">
+            <Combobox
+              onChange={(value: { value: string; label: string }) => {
+                onKeySelect(value);
+                onKeyQueryChange('');
+              }}
+              displayValue={() => keyQuery}
+              onInputChange={onKeyQueryChange}
+              options={getFilteredKeys}
+              disabledMessage="Max keys reached"
+              size="SM"
+              immediate
+              disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+              placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
+              emptyMessage="No matching keys found"
+            />
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <FieldLabel label="Step Duration" description="Time to wait before executing the next step." />
+          </div>
+          <div className="flex items-center gap-3">
+            <SelectMenuBasic
+              size="SM"
+              fullWidth
+              value={step.delay.toString()}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+              options={PRESET_DELAYS}
+            />
+          </div>
+        </div>
+      </div>
+    </Card>
+  );
+} 
\ No newline at end of file
diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx
index ec5906c..09a94a6 100644
--- a/ui/src/components/VirtualKeyboard.tsx
+++ b/ui/src/components/VirtualKeyboard.tsx
@@ -11,7 +11,7 @@ import "react-simple-keyboard/build/css/index.css";
 
 import { useHidStore, useUiStore } from "@/hooks/stores";
 import { cx } from "@/cva.config";
-import { keys, modifiers } from "@/keyboardMappings";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
 import useKeyboard from "@/hooks/useKeyboard";
 import DetachIconRaw from "@/assets/detach-icon.svg";
 import AttachIconRaw from "@/assets/attach-icon.svg";
@@ -260,136 +260,7 @@ function KeyboardWrapper() {
                           buttons: "CtrlAltDelete AltMetaEscape",
                         },
                       ]}
-                      display={{
-                        CtrlAltDelete: "Ctrl + Alt + Delete",
-                        AltMetaEscape: "Alt + Meta + Escape",
-                        Escape: "esc",
-                        Tab: "tab",
-                        Backspace: "backspace",
-                        "(Backspace)": "backspace",
-                        Enter: "enter",
-                        CapsLock: "caps lock",
-                        ShiftLeft: "shift",
-                        ShiftRight: "shift",
-                        ControlLeft: "ctrl",
-                        AltLeft: "alt",
-                        AltRight: "alt",
-                        MetaLeft: "meta",
-                        MetaRight: "meta",
-                        KeyQ: "q",
-                        KeyW: "w",
-                        KeyE: "e",
-                        KeyR: "r",
-                        KeyT: "t",
-                        KeyY: "y",
-                        KeyU: "u",
-                        KeyI: "i",
-                        KeyO: "o",
-                        KeyP: "p",
-                        KeyA: "a",
-                        KeyS: "s",
-                        KeyD: "d",
-                        KeyF: "f",
-                        KeyG: "g",
-                        KeyH: "h",
-                        KeyJ: "j",
-                        KeyK: "k",
-                        KeyL: "l",
-                        KeyZ: "z",
-                        KeyX: "x",
-                        KeyC: "c",
-                        KeyV: "v",
-                        KeyB: "b",
-                        KeyN: "n",
-                        KeyM: "m",
-
-                        "(KeyQ)": "Q",
-                        "(KeyW)": "W",
-                        "(KeyE)": "E",
-                        "(KeyR)": "R",
-                        "(KeyT)": "T",
-                        "(KeyY)": "Y",
-                        "(KeyU)": "U",
-                        "(KeyI)": "I",
-                        "(KeyO)": "O",
-                        "(KeyP)": "P",
-                        "(KeyA)": "A",
-                        "(KeyS)": "S",
-                        "(KeyD)": "D",
-                        "(KeyF)": "F",
-                        "(KeyG)": "G",
-                        "(KeyH)": "H",
-                        "(KeyJ)": "J",
-                        "(KeyK)": "K",
-                        "(KeyL)": "L",
-                        "(KeyZ)": "Z",
-                        "(KeyX)": "X",
-                        "(KeyC)": "C",
-                        "(KeyV)": "V",
-                        "(KeyB)": "B",
-                        "(KeyN)": "N",
-                        "(KeyM)": "M",
-                        Digit1: "1",
-                        Digit2: "2",
-                        Digit3: "3",
-                        Digit4: "4",
-                        Digit5: "5",
-                        Digit6: "6",
-                        Digit7: "7",
-                        Digit8: "8",
-                        Digit9: "9",
-                        Digit0: "0",
-
-                        "(Digit1)": "!",
-                        "(Digit2)": "@",
-                        "(Digit3)": "#",
-                        "(Digit4)": "$",
-                        "(Digit5)": "%",
-                        "(Digit6)": "^",
-                        "(Digit7)": "&",
-                        "(Digit8)": "*",
-                        "(Digit9)": "(",
-                        "(Digit0)": ")",
-                        Minus: "-",
-                        "(Minus)": "_",
-
-                        Equal: "=",
-                        "(Equal)": "+",
-                        BracketLeft: "[",
-                        BracketRight: "]",
-                        "(BracketLeft)": "{",
-                        "(BracketRight)": "}",
-                        Backslash: "\\",
-                        "(Backslash)": "|",
-
-                        Semicolon: ";",
-                        "(Semicolon)": ":",
-                        Quote: "'",
-                        "(Quote)": '"',
-                        Comma: ",",
-                        "(Comma)": "<",
-                        Period: ".",
-                        "(Period)": ">",
-                        Slash: "/",
-                        "(Slash)": "?",
-                        Space: " ",
-                        Backquote: "`",
-                        "(Backquote)": "~",
-                        IntlBackslash: "\\",
-
-                        F1: "F1",
-                        F2: "F2",
-                        F3: "F3",
-                        F4: "F4",
-                        F5: "F5",
-                        F6: "F6",
-                        F7: "F7",
-                        F8: "F8",
-                        F9: "F9",
-                        F10: "F10",
-                        F11: "F11",
-                        F12: "F12",
-                      }}
+                      display={keyDisplayMap}
                       layout={{
                         default: [
                           "CtrlAltDelete AltMetaEscape",
diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx
index 970867a..be69899 100644
--- a/ui/src/components/WebRTCVideo.tsx
+++ b/ui/src/components/WebRTCVideo.tsx
@@ -13,6 +13,7 @@ import { useResizeObserver } from "@/hooks/useResizeObserver";
 import { cx } from "@/cva.config";
 import VirtualKeyboard from "@components/VirtualKeyboard";
 import Actionbar from "@components/ActionBar";
+import MacroBar from "@/components/MacroBar";
 import InfoBar from "@components/InfoBar";
 import useKeyboard from "@/hooks/useKeyboard";
 import { useJsonRpc } from "@/hooks/useJsonRpc";
@@ -553,16 +554,19 @@ export default function WebRTCVideo() {
 
   return (
     <div className="grid h-full w-full grid-rows-layout">
-      <div className="min-h-[39.5px]">
-        <fieldset disabled={peerConnection?.connectionState !== "connected"}>
-          <Actionbar
-            requestFullscreen={async () =>
-              videoElm.current?.requestFullscreen({
-                navigationUI: "show",
-              })
-            }
-          />
-        </fieldset>
+      <div className="min-h-[39.5px] flex flex-col">
+        <div className="flex flex-col">
+          <fieldset disabled={peerConnection?.connectionState !== "connected"} className="contents">
+            <Actionbar
+              requestFullscreen={async () =>
+                videoElm.current?.requestFullscreen({
+                  navigationUI: "show",
+                })
+              }
+            />
+            <MacroBar />
+          </fieldset>
+        </div>
       </div>
 
       <div
diff --git a/ui/src/constants/macros.ts b/ui/src/constants/macros.ts
new file mode 100644
index 0000000..853cfe9
--- /dev/null
+++ b/ui/src/constants/macros.ts
@@ -0,0 +1,5 @@
+export const DEFAULT_DELAY = 50;
+export const MAX_STEPS_PER_MACRO = 10;
+export const MAX_KEYS_PER_STEP = 10;
+export const MAX_TOTAL_MACROS = 25; 
+export const COPY_SUFFIX = "(copy)";
\ No newline at end of file
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts
index f30c28c..0fa4121 100644
--- a/ui/src/hooks/stores.ts
+++ b/ui/src/hooks/stores.ts
@@ -1,5 +1,18 @@
 import { create } from "zustand";
 import { createJSONStorage, persist } from "zustand/middleware";
+import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Define the JsonRpc types for better type checking
+interface JsonRpcResponse {
+  jsonrpc: string;
+  result?: unknown;
+  error?: {
+    code: number;
+    message: string;
+    data?: unknown;
+  };
+  id: number | string | null;
+}
 
 // Utility function to append stats to a Map
 const appendStatToMap = <T extends { timestamp: number }>(
@@ -649,3 +662,146 @@ export const useDeviceStore = create<DeviceState>(set => ({
   setAppVersion: version => set({ appVersion: version }),
   setSystemVersion: version => set({ systemVersion: version }),
 }));
+
+export interface KeySequenceStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+export interface KeySequence {
+  id: string;
+  name: string;
+  steps: KeySequenceStep[];
+  sortOrder?: number;
+}
+
+export interface MacrosState {
+  macros: KeySequence[];
+  loading: boolean;
+  initialized: boolean;
+  loadMacros: () => Promise<void>;
+  saveMacros: (macros: KeySequence[]) => Promise<void>;
+  sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void) | null;
+  setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void;
+}
+
+export const generateMacroId = () => {
+  return Math.random().toString(36).substring(2, 9);
+};
+
+export const useMacrosStore = create<MacrosState>((set, get) => ({
+  macros: [],
+  loading: false,
+  initialized: false,
+  sendFn: null,
+
+  setSendFn: (sendFn) => {
+    set({ sendFn });
+  },
+
+  loadMacros: async () => {
+    if (get().initialized) return;
+
+    const { sendFn } = get();
+    if (!sendFn) {
+      console.warn("JSON-RPC send function not available.");
+      return;
+    }
+
+    set({ loading: true });
+
+    try {
+      await new Promise<void>((resolve, reject) => {
+        sendFn("getKeyboardMacros", {}, (response) => {
+          if (response.error) {
+            console.error("Error loading macros:", response.error);
+            reject(new Error(response.error.message));
+            return;
+          }
+
+          const macros = (response.result as KeySequence[]) || [];
+
+          const sortedMacros = [...macros].sort((a, b) => {
+            if (a.sortOrder !== undefined && b.sortOrder !== undefined) {
+              return a.sortOrder - b.sortOrder;
+            }
+            if (a.sortOrder !== undefined) return -1;
+            if (b.sortOrder !== undefined) return 1;
+            return 0;
+          });
+
+          set({
+            macros: sortedMacros,
+            initialized: true
+          });
+
+          resolve();
+        });
+      });
+    } catch (error) {
+      console.error("Failed to load macros:", error);
+    } finally {
+      set({ loading: false });
+    }
+  },
+
+  saveMacros: async (macros: KeySequence[]) => {
+    const { sendFn } = get();
+    if (!sendFn) {
+      console.warn("JSON-RPC send function not available.");
+      throw new Error("JSON-RPC send function not available");
+    }
+
+    if (macros.length > MAX_TOTAL_MACROS) {
+      console.error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`);
+      throw new Error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`);
+    }
+
+    for (const macro of macros) {
+      if (macro.steps.length > MAX_STEPS_PER_MACRO) {
+        console.error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`);
+        throw new Error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`);
+      }
+
+      for (let i = 0; i < macro.steps.length; i++) {
+        const step = macro.steps[i];
+        if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
+          console.error(`Cannot save: macro "${macro.name}" step ${i+1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
+          throw new Error(`Cannot save: macro "${macro.name}" step ${i+1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
+        }
+      }
+    }
+
+    set({ loading: true });
+
+    try {
+      const macrosWithSortOrder = macros.map((macro, index) => ({
+        ...macro,
+        sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index
+      }));
+
+      const response = await new Promise<JsonRpcResponse>((resolve) => {
+        sendFn("setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, (response) => {
+          resolve(response);
+        });
+      });
+
+      if (response.error) {
+        console.error("Error saving macros:", response.error);
+        const errorMessage = typeof response.error.data === 'string'
+          ? response.error.data
+          : response.error.message || "Failed to save macros";
+        throw new Error(errorMessage);
+      }
+
+      // Only update the store if the request was successful
+      set({ macros: macrosWithSortOrder });
+    } catch (error) {
+      console.error("Failed to save macros:", error);
+      throw error;
+    } finally {
+      set({ loading: false });
+    }
+  }
+}));
\ No newline at end of file
diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts
index 137fc8b..0ce1eef 100644
--- a/ui/src/hooks/useKeyboard.ts
+++ b/ui/src/hooks/useKeyboard.ts
@@ -2,6 +2,7 @@ import { useCallback } from "react";
 
 import { useHidStore, useRTCStore } from "@/hooks/stores";
 import { useJsonRpc } from "@/hooks/useJsonRpc";
+import { keys, modifiers } from "@/keyboardMappings";
 
 export default function useKeyboard() {
   const [send] = useJsonRpc();
@@ -28,5 +29,28 @@ export default function useKeyboard() {
     sendKeyboardEvent([], []);
   }, [sendKeyboardEvent]);
 
-  return { sendKeyboardEvent, resetKeyboardState };
+  const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
+    for (const [index, step] of steps.entries()) {
+      const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
+      const modifierValues = step.modifiers?.map(mod => modifiers[mod]).filter(Boolean) || [];
+
+      // If the step has keys and/or modifiers, press them and hold for the delay
+      if (keyValues.length > 0 || modifierValues.length > 0) {
+        sendKeyboardEvent(keyValues, modifierValues);
+        await new Promise(resolve => setTimeout(resolve, step.delay || 50));
+
+        resetKeyboardState();
+      } else {
+        // This is a delay-only step, just wait for the delay amount
+        await new Promise(resolve => setTimeout(resolve, step.delay || 50));
+      }
+
+      // Add a small pause between steps if not the last step
+      if (index < steps.length - 1) {
+        await new Promise(resolve => setTimeout(resolve, 10));
+      }
+    }
+  };
+
+  return { sendKeyboardEvent, resetKeyboardState, executeMacro };
 }
diff --git a/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts
index ffc781c..347939a 100644
--- a/ui/src/keyboardMappings.ts
+++ b/ui/src/keyboardMappings.ts
@@ -212,3 +212,80 @@ export const modifiers = {
   MetaLeft: 0x08,
   MetaRight: 0x80,
 } as Record<string, number>;
+
+export const modifierDisplayMap: Record<string, string> = {
+  ControlLeft: "Left Ctrl",
+  ControlRight: "Right Ctrl",
+  ShiftLeft: "Left Shift",
+  ShiftRight: "Right Shift",
+  AltLeft: "Left Alt",
+  AltRight: "Right Alt",
+  MetaLeft: "Left Meta",
+  MetaRight: "Right Meta",
+} as Record<string, string>;
+
+export const keyDisplayMap: Record<string, string> = {
+  CtrlAltDelete: "Ctrl + Alt + Delete",
+  AltMetaEscape: "Alt + Meta + Escape",
+  Escape: "esc",
+  Tab: "tab",
+  Backspace: "backspace",
+  Enter: "enter",
+  CapsLock: "caps lock",
+  ShiftLeft: "shift",
+  ShiftRight: "shift",
+  ControlLeft: "ctrl",
+  AltLeft: "alt",
+  AltRight: "alt",
+  MetaLeft: "meta",
+  MetaRight: "meta",
+  Space: " ",
+  Home: "home",
+  PageUp: "pageup",
+  Delete: "delete",
+  End: "end",
+  PageDown: "pagedown",
+  ArrowLeft: "←",
+  ArrowRight: "→",
+  ArrowUp: "↑",
+  ArrowDown: "↓",
+  
+  // Letters
+  KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e",
+  KeyF: "f", KeyG: "g", KeyH: "h", KeyI: "i", KeyJ: "j",
+  KeyK: "k", KeyL: "l", KeyM: "m", KeyN: "n", KeyO: "o",
+  KeyP: "p", KeyQ: "q", KeyR: "r", KeyS: "s", KeyT: "t",
+  KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y",
+  KeyZ: "z",
+
+  // Numbers
+  Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5",
+  Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0",
+
+  // Symbols
+  Minus: "-",
+  Equal: "=",
+  BracketLeft: "[",
+  BracketRight: "]",
+  Backslash: "\\",
+  Semicolon: ";",
+  Quote: "'",
+  Comma: ",",
+  Period: ".",
+  Slash: "/",
+  Backquote: "`",
+  IntlBackslash: "\\",
+
+  // Function keys
+  F1: "F1", F2: "F2", F3: "F3", F4: "F4",
+  F5: "F5", F6: "F6", F7: "F7", F8: "F8",
+  F9: "F9", F10: "F10", F11: "F11", F12: "F12",
+
+  // Numpad
+  Numpad0: "Num 0", Numpad1: "Num 1", Numpad2: "Num 2",
+  Numpad3: "Num 3", Numpad4: "Num 4", Numpad5: "Num 5",
+  Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8",
+  Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -",
+  NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .",
+  NumpadEnter: "Num Enter"
+};
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index 066ee57..e09a2a9 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -43,6 +43,9 @@ import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
 import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
 import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
 import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
+import SettingsMacrosRoute from "./routes/devices.$id.settings.macros";
+import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add";
+import SettingsMacrosEditRoute from "./routes/devices.$id.settings.macros.edit";
 
 export const isOnDevice = import.meta.env.MODE === "device";
 export const isInCloud = !isOnDevice;
@@ -175,6 +178,23 @@ if (isOnDevice) {
               path: "appearance",
               element: <SettingsAppearanceRoute />,
             },
+            {
+              path: "macros",
+              children: [
+                {
+                  index: true,
+                  element: <SettingsMacrosRoute />,
+                },
+                {
+                  path: "add",
+                  element: <SettingsMacrosAddRoute />,
+                },
+                {
+                  path: ":macroId/edit",
+                  element: <SettingsMacrosEditRoute />,
+                },
+              ],
+            },
           ],
         },
       ],
@@ -283,6 +303,23 @@ if (isOnDevice) {
                       path: "appearance",
                       element: <SettingsAppearanceRoute />,
                     },
+                    {
+                      path: "macros",
+                      children: [
+                        {
+                          index: true,
+                          element: <SettingsMacrosRoute />,
+                        },
+                        {
+                          path: "add",
+                          element: <SettingsMacrosAddRoute />,
+                        },
+                        {
+                          path: ":macroId/edit",
+                          element: <SettingsMacrosEditRoute />,
+                        },
+                      ],
+                    },
                   ],
                 },
               ],
diff --git a/ui/src/routes/devices.$id.settings.macros.add.tsx b/ui/src/routes/devices.$id.settings.macros.add.tsx
new file mode 100644
index 0000000..1b3ce30
--- /dev/null
+++ b/ui/src/routes/devices.$id.settings.macros.add.tsx
@@ -0,0 +1,63 @@
+import { useNavigate } from "react-router-dom";
+import { useState } from "react";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { MacroForm } from "@/components/MacroForm";
+import { DEFAULT_DELAY } from "@/constants/macros";
+import notifications from "@/notifications";
+
+export default function SettingsMacrosAddRoute() {
+  const { macros, saveMacros } = useMacrosStore();
+  const [isSaving, setIsSaving] = useState(false);
+  const navigate = useNavigate();
+
+  const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+    return macros.map((macro, index) => ({
+      ...macro,
+      sortOrder: index + 1,
+    }));
+  };
+
+  const handleAddMacro = async (macro: Partial<KeySequence>) => {
+    setIsSaving(true);
+    try {
+      const newMacro: KeySequence = {
+        id: generateMacroId(),
+        name: macro.name!.trim(),
+        steps: macro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, newMacro]));
+      notifications.success(`Macro "${newMacro.name}" created successfully`);
+      navigate("../");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to create macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Add New Macro"
+        description="Create a new keyboard macro"
+      />
+      <MacroForm
+        initialData={{
+          name: "",
+          steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+        }}
+        onSubmit={handleAddMacro}
+        onCancel={() => navigate("../")}
+        isSubmitting={isSaving}
+      />
+    </div>
+  );
+} 
\ No newline at end of file
diff --git a/ui/src/routes/devices.$id.settings.macros.edit.tsx b/ui/src/routes/devices.$id.settings.macros.edit.tsx
new file mode 100644
index 0000000..336fe85
--- /dev/null
+++ b/ui/src/routes/devices.$id.settings.macros.edit.tsx
@@ -0,0 +1,134 @@
+import { useNavigate, useParams } from "react-router-dom";
+import { useState, useEffect } from "react";
+import { LuTrash2 } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { MacroForm } from "@/components/MacroForm";
+import notifications from "@/notifications";
+import { Button } from "@/components/Button";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosEditRoute() {
+  const { macros, saveMacros } = useMacrosStore();
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+  const navigate = useNavigate();
+  const { macroId } = useParams<{ macroId: string }>();
+  const [macro, setMacro] = useState<KeySequence | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  useEffect(() => {
+    const foundMacro = macros.find(m => m.id === macroId);
+    if (foundMacro) {
+      setMacro({
+        ...foundMacro,
+        steps: foundMacro.steps.map(step => ({
+          ...step,
+          keys: Array.isArray(step.keys) ? step.keys : [],
+          modifiers: Array.isArray(step.modifiers) ? step.modifiers : [],
+          delay: typeof step.delay === 'number' ? step.delay : 0
+        }))
+      });
+    } else {
+      navigate("../");
+    }
+  }, [macroId, macros, navigate]);
+
+  const handleUpdateMacro = async (updatedMacro: Partial<KeySequence>) => {
+    if (!macro) return;
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === macro.id ? {
+          ...macro,
+          name: updatedMacro.name!.trim(),
+          steps: updatedMacro.steps || [],
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      notifications.success(`Macro "${updatedMacro.name}" updated successfully`);
+      navigate("../");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  };
+
+  const handleDeleteMacro = async () => {
+    if (!macro) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macro.id));
+      await saveMacros(updatedMacros);
+      notifications.success(`Macro "${macro.name}" deleted successfully`);
+      navigate("../macros");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  if (!macro) return null;
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <SettingsPageHeader
+          title="Edit Macro"
+          description="Modify your keyboard macro"
+        />
+        <Button
+          size="SM"
+          theme="light"
+          text="Delete Macro"
+          className="text-red-500 dark:text-red-400"
+          LeadingIcon={LuTrash2}
+          onClick={() => setShowDeleteConfirm(true)}
+          disabled={isDeleting}
+        />
+      </div>
+      <MacroForm
+        initialData={macro}
+        onSubmit={handleUpdateMacro}
+        onCancel={() => navigate("../")}
+        isSubmitting={isUpdating}
+        submitText="Save Changes"
+      />
+
+      <ConfirmDialog
+        open={showDeleteConfirm}
+        onClose={() => setShowDeleteConfirm(false)}
+        title="Delete Macro"
+        description="Are you sure you want to delete this macro? This action cannot be undone."
+        variant="danger"
+        confirmText={isDeleting ? "Deleting" : "Delete"}
+        onConfirm={() => {
+          handleDeleteMacro();
+          setShowDeleteConfirm(false);
+        }}
+        isConfirming={isDeleting}
+      />
+    </div>
+  );
+} 
\ No newline at end of file
diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx
new file mode 100644
index 0000000..f809f57
--- /dev/null
+++ b/ui/src/routes/devices.$id.settings.macros.tsx
@@ -0,0 +1,306 @@
+import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX, DEFAULT_DELAY } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+import LoadingSpinner from "@/components/LoadingSpinner";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = useCallback(async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [isMaxMacrosReached, macros, saveMacros, setActionLoadingId]);
+
+  const handleMoveMacro = useCallback(async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [macros, saveMacros, setActionLoadingId]);
+
+  const handleDeleteMacro = useCallback(async () => {
+    if (!macroToDelete?.id) return;
+
+    setActionLoadingId(macroToDelete.id);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id));
+      await saveMacros(updatedMacros);
+      notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
+      setShowDeleteConfirm(false);
+      setMacroToDelete(null);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to delete macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [macroToDelete, macros, saveMacros]);
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          {step.delay !== DEFAULT_DELAY && (
+                             <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                          )}
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                className="text-red-500 dark:text-red-400"
+                theme="light"
+                LeadingIcon={LuTrash2}
+                onClick={() => {
+                  setMacroToDelete(macro);
+                  setShowDeleteConfirm(true);
+                }}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Delete macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                text="Edit"
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+
+      <ConfirmDialog
+        open={showDeleteConfirm}
+        onClose={() => {
+          setShowDeleteConfirm(false);
+          setMacroToDelete(null);
+        }}
+        title="Delete Macro"
+        description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
+        variant="danger"
+        confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
+        onConfirm={handleDeleteMacro}
+        isConfirming={actionLoadingId === macroToDelete?.id}
+      />
+    </div>
+  ), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro]);
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <SettingsPageHeader
+          title="Keyboard Macros"
+          description={`Combine keystrokes into a single action for faster workflows.`}
+        />
+        { macros.length > 0 && (
+          <div className="flex items-center pl-2">
+            <Button
+              size="SM"
+              theme="primary"
+              text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+              onClick={() => navigate("add")}
+              disabled={isMaxMacrosReached}
+              aria-label="Add new macro"
+            />
+          </div>
+        )}
+      </div>
+
+      <div className="space-y-4">
+        {loading && macros.length === 0 ? (
+          <EmptyCard
+            IconElm={LuCommand}
+            headline="Loading macros..."
+            BtnElm={
+              <div className="my-2 flex flex-col items-center space-y-2 text-center">
+                <LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
+              </div>
+            }
+          />
+        ) : macros.length === 0 ? (
+          <EmptyCard
+            IconElm={LuCommand}
+            headline="Create Your First Macro"
+            BtnElm={
+              <Button
+                size="SM"
+                theme="primary"
+                text="Add New Macro"
+                onClick={() => navigate("add")}
+                disabled={isMaxMacrosReached}
+                aria-label="Add new macro"
+              />
+            }
+          />
+        ) : MacroList}
+      </div>
+    </div>
+  );
+}
diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx
index 4742445..db7d6b0 100644
--- a/ui/src/routes/devices.$id.settings.tsx
+++ b/ui/src/routes/devices.$id.settings.tsx
@@ -8,6 +8,7 @@ import {
   LuWrench,
   LuArrowLeft,
   LuPalette,
+  LuCommand,
 } from "react-icons/lu";
 import React, { useEffect, useRef, useState } from "react";
 
@@ -195,6 +196,17 @@ export default function SettingsRoute() {
                     </div>
                   </NavLink>
                 </div>
+                <div className="shrink-0">
+                  <NavLink
+                    to="macros"
+                    className={({ isActive }) => (isActive ? "active" : "")}
+                  >
+                    <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
+                      <LuCommand className="h-4 w-4 shrink-0" />
+                      <h1>Keyboard Macros</h1>
+                    </div>
+                  </NavLink>
+                </div>
                 <div className="shrink-0">
                   <NavLink
                     to="advanced"