9 Profile Card Design Styles, Copy-Paste Ready
Profile card design is the job of deciding what material a fixed block of content wears: avatar, name, role, stats, follow button.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Glassmorphism
- 02 Neo-brutalist offset
- 03 Claymorphism
- 04 Minimal line
- 05 Spinning gradient border
- 06 Dark glow ring
- 07 3D flip contact
- 08 Social stat count-up
- 09 Team grid expand
The first six change nothing but the surface of a single card. All six lay out the same fields — avatar, name, role, two stats, and a follow button — so reading one style block against the next shows exactly which declarations carry the look. The order tracks how much each one does with light: glass that blurs whatever sits behind it (01), then neo-brutalism that trades light for shadow used as a solid shape (02), clay that stacks inner and outer shadows into volume (03), a minimal card that throws away every shadow and keeps only hairlines (04), a rim that glows on its own (05), and neon that bleeds in the dark (06). The last three add structure instead of surface: a back face (07), time (08), and neighbors (09). In every one of them the follow button really flips aria-pressed and swaps both label and icon.
01Glassmorphism
Two color blobs drift behind the card, and the translucent surface blurs them as they pass. A single declaration turns the surface into frosted glass, but the effect only shows if something real sits behind it.
.gf__card {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, .30);
border: 1px solid rgba(255, 255, 255, .62);
border-radius: $r-card;
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
color: $subject-ink;
}
.gf__blob--a {
top: -24px; left: -30px;
width: 140px; height: 140px;
background: radial-gradient(circle at 38% 38%, $subject-pink, rgba($subject-pink, 0) 72%);
}
02Neo-brutalist offset
A heavy outline and a flat shadow pushed to the lower right lift the card like a piece of cut paper. Press it and the card lands exactly on the shadow, which disappears underneath it.
.nb__card {
border: 3px solid $subject-ink;
border-radius: $r-card;
box-shadow: 8px 8px 0 $subject-ink;
}
@keyframes nb-press {
0%, 100% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
9% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
20% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
46% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
55% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
68% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
}
03Claymorphism
Three stacked shadows make the card look like a puffed lump of clay. The squishy volume comes from one shadow spreading outward, a bright inner highlight, and a dark inner hollow.
.cp__card {
border-radius: $r-card;
background: rgba($subject-pink, .28);
box-shadow:
0 18px 30px rgba(23, 20, 26, .14),
inset 6px 6px 12px rgba(255, 255, 255, .9),
inset -6px -6px 12px rgba(23, 20, 26, .10);
}
@keyframes cp-breathe {
0%, 100% { transform: scale(1); }
44% { transform: scale(1.12); }
88% { transform: scale(1); }
}
04Minimal line
A white card on a white page has no background contrast to work with. Hairlines and whitespace carry the shape, and the only ornament is a 2px rule that draws itself from the left, first under the name and then along the bottom of the button.
.ml__line {
display: block;
height: 2px;
background: $subject-ink;
transform: scaleX(0);
transform-origin: left center;
transition: transform $duration $easing;
}
.ml__name:hover .ml__line--name,
.ml__follow:hover .ml__line--btn,
.ml__follow:focus-visible .ml__line--btn,
.ml__follow[aria-pressed="true"] .ml__line--btn { transform: scaleX(1); }
05Spinning gradient border
A conic gradient sweeps around the card and an inner panel covers it, leaving a 4px rim of light. Registering the angle as a typed custom property is the whole trick.
@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.gr__rim {
padding: $sp-1;
background: conic-gradient(from var(--angle),
$subject-blue, $subject-pink, $subject-mint, $subject-lilac, $subject-blue);
animation-name: gr-spin;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes gr-spin { to { --angle: 360deg; } }
06Dark glow ring
On a dark card, a glow around the avatar ring breathes brighter and dimmer. Boxes get box-shadow inside and out, while the initials get a drop-shadow that follows the glyph outline instead of a rectangle.
.dg__ring {
border: 2px solid $subject-mint;
border-radius: 50%;
box-shadow: 0 0 18px rgba($subject-mint, .55), inset 0 0 10px rgba($subject-mint, .45);
}
.dg__init {
color: $subject-cream;
filter: drop-shadow(0 0 6px rgba($subject-mint, .9));
}
@keyframes dg-breathe {
0% { opacity: .18; transform: scale(.92); }
38% { opacity: 1; transform: scale(1.10); }
62% { opacity: 1; transform: scale(1.10); }
}
073D flip contact
The card turns half a revolution around its vertical axis, so the intro on the front becomes contact details on the back. Both faces have to hide their own back side, or the text on the far face shows through mirrored.
.fc { perspective: 900px; width: 300px; height: 212px; }
.fc__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.fc__inner.is-back { transform: rotateY(180deg); }
.fc__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.fc__face--back { transform: rotateY(180deg); }
08Social stat count-up
Three numbers climb from zero to their real values one frame at a time. Pressing follow moves the follower target by one and re-counts that number alone, with a run id so an older count cannot keep writing over the new one.
function countTo(i, to, ms) {
var run = ++runs[i];
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
current[i] = to;
nums[i].textContent = commas(to);
return;
}
var from = current[i], t0 = performance.now();
function frame(now) {
if (runs[i] !== run) return;
var p = Math.min(1, (now - t0) / ms);
current[i] = Math.round(from + (to - from) * easeOutCubic(p));
nums[i].textContent = commas(current[i]);
if (p < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
09Team grid expand
When one tile widens, the role and follow button rise from below while the other two narrow over the same beat, so the row keeps its total width. Moving focus with the keyboard has to suppress scrolling.
mains[i].addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
root.classList.remove('is-demo');
var next = (i + (e.key === 'ArrowRight' ? 1 : tiles.length - 1)) % tiles.length;
open(next);
mains[next].focus({ preventScroll: true });
});
Where it breaks — the trap
The trap in this set was not a style. It was the thumbnail. The poster used in the grid and in share cards comes from sampling two seconds into 24 frames and picking the one that differs most from the frame before it, and for 07 that was always the card seen exactly edge-on: a single white line on an orange field. It is not bad luck. The on-screen width of a face rotated around its vertical axis is the cosine of the angle, so its rate of change is the sine, which peaks at 90 degrees. Linear or eased, the frame that changed most is the edge.
Three more items had the same disease. Fading in the three stat blocks of 08 and the role and button of 09 with opacity produced a half-transparent ghost as the poster, and the rule in 04 was drawn at a constant rate, so the stub right after the start got picked and read like a typo. The cure was the same idea in all three: kill the stretch where the change creeps along gradually so the finished state lands inside a single sample. In 08 and 09 that state is swapped in one frame with steps(1, end), lined up with the finished state; in 04, where the line still has to be drawn, the last part of the rule was packed into one sample interval of 83.3 milliseconds, so the frame where the line reaches full width is the biggest change. For 07 only the preview loop became a cut; a real click still flips through a smooth transition. Cutting also dropped the changed-pixel area from 56.2% to 44.0% because the sweep across the stage disappeared, so small opacity-free motion went onto both faces and lifted moving frames from 10 to 18. A cut on its own only moves two frames, which measures as no animation at all.
Delayed animations cost one more round. Give an element animation-delay without animation-fill-mode: backwards and it renders in its authored state for the whole delay. All three stat blocks in 08 were visible in the first frame, went dark, then came back in sequence. During the delay the animation has not been applied yet, and the frame grabber records that moment as it is. Writing the stagger with the animation shorthand has the same flavor of bug, since the shorthand resets the delay to zero: every staggered item here — 03, 04, 06, 07, 08, and 09 — writes the delay as a longhand property.
Accessibility (reduced-motion)
Color contrast was calculated on the composited colors rather than guessed. Under a translucent panel the real background is whatever the blend produces, so white text can look fine and still fail.
| Item | Text / background | Ratio | Verdict |
|---|---|---|---|
| 01 glass | ink / frost panel (255, 130, 98) | 7.49:1 | pass |
| 01 glass (rejected) | white / same frost panel | 2.44:1 | fail |
| 02 neo-brutalist | white / blue pill | 4.53:1 | pass |
| 03 clay | ink at 62% / pink card | 4.79:1 | pass |
| 04 minimal | ink at 64% / white card | 5.44:1 | pass |
| 06 dark | mint status dot / dark panel | 8.64:1 | pass |
| 09 team | cream / expanded tile | 10.99:1 | pass |
Item 01 started out with white text at 2.44:1, which is below the bar. Making the frost panel whiter and switching the text to ink took it to 7.49:1. The secondary text in 04 was also short at 4.04:1 with 55% opacity, so it went up to 64%. With the contrast fixed, all nine sources live in a zip that opens with the archive password kx4qjk9q, and the vanilla and React builds use the same values.
Under prefers-reduced-motion: reduce all nine drop their CSS autoplay loop. The blobs in 01 stand still, 02 keeps only the real :active press instead of the repeat, the avatar in 03 rests at its normal size, the halo in 06 settles at its dim base opacity while the ring and its glow stay lit, and the rim in 05 stops turning while the gradient stays visible. Item 07 loses the flip transition and shows the back instantly on click, 08 drops the staggered entrance of its stat blocks and bars, and 09 changes state without the widening transition. The count-up in 08 is the one piece of motion written in JavaScript rather than CSS, so the media query does not reach it on its own: both the vanilla and React builds read matchMedia('(prefers-reduced-motion: reduce)') once and write the final values instead of counting. In every case the state itself — followed or not, expanded or not — survives. The 3D properties behind 07 are documented in the MDN page on transform-style.
For more cards in the same family, see 9 CSS card hover effects and 9 login form design patterns.
FAQ
Are initials instead of an avatar image really fine?
They are. All nine fill a circle with a CSS color or gradient and drop two initials in it, with no image file anywhere. On an account that has no photo yet this hides the empty slot better than a gray silhouette does, and it adds zero requests. When a photo arrives later, cover the inside of the circle with a background-image and leave the outer size and border exactly as they are.
I added backdrop-filter and nothing blurs
Nothing is behind it to blur. backdrop-filter works on what sits behind the element rather than the element itself, so over a flat background you get a blurred flat color and see no difference at all. Something has to be back there, like the drifting blobs in 01 or a photo. A fully opaque card background hides the result just as completely, so keep the alpha below one.
My gradient border refuses to spin
The @property registration is probably missing. A plain custom property is treated as a string, so the browser cannot interpolate between two values and the angle jumps from 0 to 360 degrees in one go, which looks frozen. Declaring syntax: "<angle>" with an initial-value the way 05 does is what makes the angle animate continuously.