diff --git a/documentation/md/style.md b/documentation/md/style.md
index 312fc628e0..dea69bfd5f 100644
--- a/documentation/md/style.md
+++ b/documentation/md/style.md
@@ -223,6 +223,14 @@ Border:
  * **`border-color`** : The colour of the node's border.
  * **`border-opacity`** : The opacity of the node's border.
 
+ Outline:
+
+ * **`outline-width`** : The size of the node's outline.
+ * **`outline-style`** : The style of the node's outline; may be `solid`, `dotted`, `dashed`, or `double`.
+ * **`outline-color`** : The colour of the node's outline.
+ * **`outline-opacity`** : The opacity of the node's outline.
+ * **`outline-offset`** : The offset of the node's outline.
+
 Padding:
 
 A padding defines an addition to a node's dimension.  For example, `padding` adds to a node's outer (i.e. total) width and height.  This can be used to add spacing between a compound node parent and its children.
diff --git a/src/collection/dimensions/bounds.js b/src/collection/dimensions/bounds.js
index 31aae6e0bc..35c82a83b0 100644
--- a/src/collection/dimensions/bounds.js
+++ b/src/collection/dimensions/bounds.js
@@ -1,5 +1,5 @@
 import * as is from '../../is';
-import { assignBoundingBox, expandBoundingBoxSides,  clearBoundingBox, expandBoundingBox, makeBoundingBox, copyBoundingBox } from '../../math';
+import { assignBoundingBox, expandBoundingBoxSides,  clearBoundingBox, expandBoundingBox, makeBoundingBox, copyBoundingBox, shiftBoundingBox, updateBoundingBox } from '../../math';
 import { defaults, getPrefixedProperty, hashIntsArray } from '../../util';
 
 let fn, elesfn;
@@ -428,6 +428,52 @@ let updateBoundsFromLabel = function( bounds, ele, prefix ){
   return bounds;
 };
 
+let updateBoundsFromOutline = function( bounds, ele ){
+  if( ele.cy().headless() ){ return; }
+
+  let outlineOpacity = ele.pstyle('outline-opacity').value;
+  let outlineWidth = ele.pstyle('outline-width').value;
+
+  if (outlineOpacity > 0 && outlineWidth > 0) {
+    let outlineOffset = ele.pstyle('outline-offset').value;
+    let nodeShape = ele.pstyle( 'shape' ).value;
+
+    let outlineSize = outlineWidth + outlineOffset;
+    let scaleX = (bounds.w + outlineSize * 2) / bounds.w;
+    let scaleY = (bounds.h + outlineSize * 2) / bounds.h;
+    let xOffset = 0;
+    let yOffset = 0;
+
+    if (["diamond", "pentagon", "round-triangle"].includes(nodeShape)) {
+      scaleX = (bounds.w + outlineSize * 2.4) / bounds.w;
+      yOffset = -outlineSize/3.6;
+    } else if (["concave-hexagon", "rhomboid", "right-rhomboid"].includes(nodeShape)) {
+      scaleX = (bounds.w + outlineSize * 2.4) / bounds.w;
+    } else if (nodeShape === "star") {
+      scaleX = (bounds.w + outlineSize * 2.8) / bounds.w;
+      scaleY = (bounds.h + outlineSize * 2.6) / bounds.h;
+      yOffset = -outlineSize / 3.8;
+    } else if (nodeShape === "triangle") {
+      scaleX = (bounds.w + outlineSize * 2.8) / bounds.w;
+      scaleY = (bounds.h + outlineSize * 2.4) / bounds.h;
+      yOffset = -outlineSize/1.4;
+    } else if (nodeShape === "vee") {
+      scaleX = (bounds.w + outlineSize * 4.4) / bounds.w;
+      scaleY = (bounds.h + outlineSize * 3.8) / bounds.h;
+      yOffset = -outlineSize * .5;
+    }
+
+    let hDelta = (bounds.h * scaleY) - bounds.h;
+    let wDelta = (bounds.w * scaleX) - bounds.w;
+    expandBoundingBoxSides(bounds, [Math.ceil(hDelta/2), Math.ceil(wDelta/2)]);
+
+    if (xOffset != 0 || yOffset !== 0) {
+      let oBounds = shiftBoundingBox(bounds, xOffset, yOffset);
+      updateBoundingBox(bounds, oBounds);
+    }
+  }
+};
+
 // get the bounding box of the elements (in raw model position)
 let boundingBoxImpl = function( ele, options ){
   let cy = ele._private.cy;
@@ -510,6 +556,9 @@ let boundingBoxImpl = function( ele, options ){
 
       updateBounds( bounds, ex1, ey1, ex2, ey2 );
 
+      if( styleEnabled && options.includeOutlines ){
+        updateBoundsFromOutline( bounds, ele );
+      }
     } else if( isEdge && options.includeEdges ){
 
       if( styleEnabled && !headless ){
@@ -735,6 +784,7 @@ let getKey = function( opts ){
   key += tf( opts.includeSourceLabels );
   key += tf( opts.includeTargetLabels );
   key += tf( opts.includeOverlays );
+  key += tf( opts.includeOutlines );
 
   return key;
 };
@@ -824,6 +874,7 @@ let defBbOpts = {
   includeTargetLabels: true,
   includeOverlays: true,
   includeUnderlays: true,
+  includeOutlines: true,
   useCache: true
 };
 
diff --git a/src/extensions/renderer/canvas/drawing-nodes.js b/src/extensions/renderer/canvas/drawing-nodes.js
index 3717f60881..2f4122d24b 100644
--- a/src/extensions/renderer/canvas/drawing-nodes.js
+++ b/src/extensions/renderer/canvas/drawing-nodes.js
@@ -1,6 +1,7 @@
 /* global Path2D */
 
 import * as is from '../../../is';
+import { expandPolygon, joinLines } from '../../../math';
 import * as util from '../../../util';
 
 let CRp = {};
@@ -74,6 +75,11 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s
   let borderColor = node.pstyle('border-color').value;
   let borderStyle = node.pstyle('border-style').value;
   let borderOpacity = node.pstyle('border-opacity').value * eleOpacity;
+  let outlineWidth = node.pstyle('outline-width').pfValue;
+  let outlineColor = node.pstyle('outline-color').value;
+  let outlineStyle = node.pstyle('outline-style').value;
+  let outlineOpacity = node.pstyle('outline-opacity').value * eleOpacity;
+  let outlineOffset = node.pstyle('outline-offset').value;
 
   context.lineJoin = 'miter'; // so borders are square with the node shape
 
@@ -85,33 +91,50 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s
     r.colorStrokeStyle( context, borderColor[0], borderColor[1], borderColor[2], bdrOpy );
   };
 
+  let setupOutlineColor = ( otlnOpy = outlineOpacity ) => {
+    r.colorStrokeStyle( context, outlineColor[0], outlineColor[1], outlineColor[2], otlnOpy );
+  };
+
   //
   // setup shape
 
-  let styleShape = node.pstyle('shape').strValue;
-  let shapePts = node.pstyle('shape-polygon-points').pfValue;
-
-  if( usePaths ){
-    context.translate( pos.x, pos.y );
-
+  let getPath = (width, height, shape, points) => {
     let pathCache = r.nodePathCache = r.nodePathCache || [];
 
     let key = util.hashStrings(
-      styleShape === 'polygon' ? styleShape + ',' + shapePts.join(',') : styleShape,
-      '' + nodeHeight,
-      '' + nodeWidth
+      shape === 'polygon' ? shape + ',' + points.join(',') : shape,
+      '' + height,
+      '' + width
     );
 
     let cachedPath = pathCache[ key ];
+    let path;
+    let cacheHit = false;
 
     if( cachedPath != null ){
       path = cachedPath;
-      pathCacheHit = true;
+      cacheHit = true;
       rs.pathCache = path;
     } else {
       path = new Path2D();
       pathCache[ key ] = rs.pathCache = path;
     }
+
+    return {
+      path, 
+      cacheHit
+    };
+  };
+
+  let styleShape = node.pstyle('shape').strValue;
+  let shapePts = node.pstyle('shape-polygon-points').pfValue;
+
+  if( usePaths ){
+    context.translate( pos.x, pos.y );
+
+    const shapePath = getPath(nodeWidth, nodeHeight, styleShape, shapePts);
+    path = shapePath.path;
+    pathCacheHit = shapePath.cacheHit;
   }
 
   let drawShape = () => {
@@ -127,11 +150,11 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s
       }
 
       r.nodeShapes[ r.getNodeShape( node ) ].draw(
-            ( path || context ),
-            npos.x,
-            npos.y,
-            nodeWidth,
-            nodeHeight );
+        ( path || context ),
+        npos.x,
+        npos.y,
+        nodeWidth,
+        nodeHeight );
     }
 
     if( usePaths ){
@@ -246,7 +269,133 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s
       if( context.setLineDash ){ // for very outofdate browsers
         context.setLineDash( [ ] );
       }
+    }
+  };
+
+  let drawOutline = () => {
+    if( outlineWidth > 0 ){
+      context.lineWidth = outlineWidth;
+      context.lineCap = 'butt';
+
+      if( context.setLineDash ){ // for very outofdate browsers
+        switch( outlineStyle ){
+          case 'dotted':
+            context.setLineDash( [ 1, 1 ] );
+            break;
+
+          case 'dashed':
+            context.setLineDash( [ 4, 2 ] );
+            break;
+
+          case 'solid':
+          case 'double':
+            context.setLineDash( [ ] );
+            break;
+        }
+      }
+
+      let npos = pos;
+
+      if( usePaths ){
+        npos = {
+          x: 0,
+          y: 0
+        };
+      }
+
+      let shape = r.getNodeShape( node );
+      
+      let scaleX = (nodeWidth + borderWidth + (outlineWidth + outlineOffset)) / nodeWidth;
+      let scaleY = (nodeHeight + borderWidth + (outlineWidth + outlineOffset)) / nodeHeight;
+      let sWidth = nodeWidth * scaleX;
+      let sHeight = nodeHeight * scaleY;
+      
+      let points = r.nodeShapes[ shape ].points;
+      let path;
+
+      if (usePaths) {
+        let outlinePath = getPath(sWidth, sHeight, shape, points);
+        path = outlinePath.path;
+      }
+
+      // draw the outline path, either by using expanded points or by scaling 
+      // the dimensions, depending on shape
+      if (shape === "ellipse") {
+        r.drawEllipsePath(path || context, npos.x, npos.y, sWidth, sHeight);
+      } else if ([
+        'round-diamond', 'round-heptagon', 'round-hexagon', 'round-octagon', 
+        'round-pentagon', 'round-polygon', 'round-triangle', 'round-tag'
+      ].includes(shape)) {
+        let sMult = 0;
+        let offsetX = 0;
+        let offsetY = 0;
+        if (shape === 'round-diamond') {
+          sMult = (borderWidth + outlineOffset + outlineWidth) * 1.4;
+        } else if (shape === 'round-heptagon') {
+          sMult = (borderWidth + outlineOffset + outlineWidth) * 1.075;
+          offsetY = -(borderWidth/2 + outlineOffset + outlineWidth) / 35;
+        } else if (shape === 'round-hexagon') {
+          sMult = (borderWidth + outlineOffset + outlineWidth) * 1.12;
+        } else if (shape === 'round-pentagon') {
+          sMult = (borderWidth + outlineOffset + outlineWidth) * 1.13;
+          offsetY = -(borderWidth/2 + outlineOffset + outlineWidth) / 15;
+        } else if (shape === 'round-tag') {
+          sMult = (borderWidth + outlineOffset + outlineWidth) * 1.12;
+          offsetX = (borderWidth/2 + outlineWidth + outlineOffset) * .07;
+        } else if (shape === 'round-triangle') {
+          sMult = (borderWidth + outlineOffset + outlineWidth) * (Math.PI/2);
+          offsetY = -(borderWidth + outlineOffset/2 + outlineWidth) / Math.PI;
+        }
+
+        if (sMult !== 0) {
+          scaleX = (nodeWidth + sMult)/nodeWidth;
+          scaleY = (nodeHeight + sMult)/nodeHeight;
+        }
 
+        r.drawRoundPolygonPath(path || context, npos.x + offsetX, npos.y + offsetY, nodeWidth * scaleX, nodeHeight * scaleY, points);
+      } else if (['roundrectangle', 'round-rectangle'].includes(shape)) {
+        r.drawRoundRectanglePath(path || context, npos.x, npos.y, sWidth, sHeight);
+      } else if (['cutrectangle', 'cut-rectangle'].includes(shape)) {
+        r.drawCutRectanglePath(path || context, npos.x, npos.y, sWidth, sHeight);
+      } else if (['bottomroundrectangle', 'bottom-round-rectangle'].includes(shape)) {
+        r.drawBottomRoundRectanglePath(path || context, npos.x, npos.y, sWidth, sHeight);
+      } else if (shape === "barrel") {
+        r.drawBarrelPath(path || context, npos.x, npos.y, sWidth, sHeight);
+      } else if (shape.startsWith("polygon") || ['rhomboid', 'right-rhomboid', 'round-tag', 'tag', 'vee'].includes(shape)) {
+        let pad = (borderWidth + outlineWidth + outlineOffset) / nodeWidth;
+        points = joinLines(expandPolygon(points, pad));
+        r.drawPolygonPath(path || context, npos.x, npos.y, nodeWidth, nodeHeight, points);
+      } else {
+        let pad = (borderWidth + outlineWidth + outlineOffset) / nodeWidth;
+        points = joinLines(expandPolygon(points, -pad));
+        r.drawPolygonPath(path || context, npos.x, npos.y, nodeWidth, nodeHeight, points);
+      }
+      
+      if( usePaths ){
+        context.stroke( path );
+      } else {
+        context.stroke();
+      }
+
+      if( outlineStyle === 'double' ){
+        context.lineWidth = borderWidth / 3;
+
+        let gco = context.globalCompositeOperation;
+        context.globalCompositeOperation = 'destination-out';
+
+        if( usePaths ){
+          context.stroke( path );
+        } else {
+          context.stroke();
+        }
+
+        context.globalCompositeOperation = gco;
+      }
+
+      // reset in case we changed the border style
+      if( context.setLineDash ){ // for very outofdate browsers
+        context.setLineDash( [ ] );
+      }
     }
   };
 
@@ -276,6 +425,8 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s
 
     context.translate( gx, gy );
 
+    setupOutlineColor();
+    drawOutline();
     setupShapeColor( ghostOpacity * bgOpacity );
     drawShape();
     drawImages( effGhostOpacity, true );
@@ -296,6 +447,8 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s
     context.translate( pos.x, pos.y );
   }
 
+  setupOutlineColor();
+  drawOutline();
   setupShapeColor();
   drawShape();
   drawImages(eleOpacity, true);
@@ -303,6 +456,7 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s
   drawBorder();
   drawPie( darkness !== 0 || borderWidth !== 0 );
   drawImages(eleOpacity, false);
+  
   darken();
 
   if( usePaths ){
diff --git a/src/style/apply.js b/src/style/apply.js
index b731f5b4a2..18debf1aba 100644
--- a/src/style/apply.js
+++ b/src/style/apply.js
@@ -357,9 +357,9 @@ styfn.updateStyleHints = function(ele){
   //
 
   if( isNode ){
-    let { nodeBody, nodeBorder, backgroundImage, compound, pie } = _p.styleKeys;
+    let { nodeBody, nodeBorder, nodeOutline, backgroundImage, compound, pie } = _p.styleKeys;
 
-    let nodeKeys = [ nodeBody, nodeBorder, backgroundImage, compound, pie ].filter(k => k != null).reduce(util.hashArrays, [
+    let nodeKeys = [ nodeBody, nodeBorder, nodeOutline, backgroundImage, compound, pie ].filter(k => k != null).reduce(util.hashArrays, [
       util.DEFAULT_HASH_SEED,
       util.DEFAULT_HASH_SEED_ALT
     ]);
diff --git a/src/style/properties.js b/src/style/properties.js
index 3804852bfd..93bc945525 100644
--- a/src/style/properties.js
+++ b/src/style/properties.js
@@ -142,7 +142,7 @@ const styfn = {};
 
         return length === 1 || length === 2 || length === 4;
       }
-    }
+    },
   };
 
   let diff = {
@@ -297,6 +297,14 @@ const styfn = {};
     { name: 'border-style', type: t.borderStyle }
   ];
 
+  let nodeOutline = [
+    { name: 'outline-color', type: t.color },
+    { name: 'outline-opacity', type: t.zeroOneNumber },
+    { name: 'outline-width', type: t.size, triggersBounds: diff.any },
+    { name: 'outline-style', type: t.borderStyle },
+    { name: 'outline-offset', type: t.size, triggersBounds: diff.any }
+  ];
+
   let backgroundImage = [
     { name: 'background-image', type: t.urls },
     { name: 'background-image-crossorigin', type: t.bgCrossOrigin },
@@ -421,6 +429,7 @@ const styfn = {};
     // node props
     ...nodeBody,
     ...nodeBorder,
+    ...nodeOutline,
     ...backgroundImage,
     ...pie,
     ...compound,
@@ -451,6 +460,7 @@ const styfn = {};
     // node props
     nodeBody,
     nodeBorder,
+    nodeOutline,
     backgroundImage,
     pie,
     compound,
@@ -622,6 +632,11 @@ styfn.getDefaultProperties = function(){
     'border-opacity': 1,
     'border-width': 0,
     'border-style': 'solid',
+    'outline-color': '#999',
+    'outline-opacity': 1,
+    'outline-width': 0,
+    'outline-offset': 0,
+    'outline-style': 'solid',
     'height': 30,
     'width': 30,
     'shape': 'ellipse',