GODRICH

Blockquote CSS: 9 Quote Box Designs (Copy-Paste)

Blockquote CSS places another writer's sentence inside your own text. Nine boxes run live below, every snippet Copy-Paste ready — from a bar-grow quote to

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine are ordered by the job a quote does in a piece of writing, not by difficulty. You meet the default bar-grow box first (01), then the mark arrives before the sentence (02), the sentence takes over the middle of the page (03), a box fills with color (04), the frame tightens from both ends (05), the speaker shows up a beat late (06), the whole thing flips to ink (07), the background deepens (08), and only at the end does the quote respond to your hand (09). All nine share the same color, spacing, and easing tokens, so you can mix several in one article without the tone drifting.

01Quote that opens as the side bar grows

The default quote for a blog post body. The thick bar on the left fills top to bottom with scaleY to claim its place, then two lines and the citation settle in a beat apart. The changed area accumulates to 3.683% of the canvas across the loop, and the box is plain enough for any magazine article.

scaleYtranslateYopacity
// The bar — grows downward (scaleY), so the area arrives split across frames instead of one cut
.qb__bar {
  border-radius: $r-pill;
  background: $color;
}
.qb.is-demo .qb__bar {
  transform-origin: top center;
  animation-name: qbBar;
  animation-duration: $duration;   // 2s loop
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
}
@keyframes qbBar {
  0%        { transform: scaleY(0); }
  26%, 80%  { transform: scaleY(1); }
  90%, 100% { transform: scaleY(0); }
}
// The two lines share keyframes with shifted percentages — a beat apart without animation-delay
@keyframes qbLine1 {
  0%, 10%   { opacity: 0; transform: translateY(8px); }
  34%, 76%  { opacity: 1; transform: translateY(0); }
  80%, 100% { opacity: 0; }
}

02Quote with a bouncing quotation mark

This one belongs at the top of a help or getting-started page. The big quotation mark above the text pops once on a cubic-bezier(.2, 1.4, .4, 1) curve, overshooting to 1.14 before landing, while the two body lines slide in from the left (accumulated area 3.828%, intensity 71.7). The overshoot curve is the kind whose y value passes 1, so it needs transform-origin: left bottom to bounce from a rooted corner.

transform-originscaletranslateX
// The mark pop — a curve that passes 1 and comes back (overshoot)
.qm.is-demo .qm__mark {
  transform-origin: left bottom;
  animation-name: qmMark;
  animation-duration: $duration;
  animation-timing-function: $ease-pop;   // cubic-bezier(.2, 1.4, .4, 1)
  animation-iteration-count: infinite;
}
@keyframes qmMark {
  0%        { opacity: 0; transform: scale(.2); }
  6%        { opacity: 1; transform: scale(.92); }
  22%       { transform: scale(1.14); }   // the overshoot before landing
  30%, 78%  { opacity: 1; transform: scale(1); }
  90%, 100% { opacity: 0; transform: scale(.9); }
}
// Body slide gets its stagger from animation-delay — the phase holds even in an infinite loop
.qm.is-demo .qm__l2 { animation-delay: 180ms; animation-fill-mode: backwards; }

03Pull quote settling into the middle of the text

The magazine move: one big sentence caught between paragraphs. The small context lines cut in with steps(1, end) before the big lines settle, and the two big lines drop with animation-delay of 60ms and 160ms (accumulated area 4.747%, intensity 119.7). Neither big line uses an opacity ramp — they move by translateY only, fully opaque from the start; the reason is measured in the trap section below.

translateYanimation-delayopacity
// The big lines — settle down 18px from above, fully opaque the whole way (no fade mid-values)
.qp.is-demo .qp__a { animation-name: qpDropA; animation-delay: 60ms; }
.qp.is-demo .qp__b { animation-name: qpDropB; animation-delay: 160ms; }
.qp.is-demo .qp__a,
.qp.is-demo .qp__b {
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-fill-mode: backwards;   // fill the delay with the first frame
}
@keyframes qpDropA {
  0%, 2%    { opacity: 1; transform: translateY(18px); }
  16%, 80%  { opacity: 1; transform: translateY(0); }
  86%, 100% { opacity: 1; transform: translateY(18px); }
}

04Quote box filling left to right

Close to a tip box. A pale background laid under the content with ::before sweeps in from the left edge using scaleX with transform-origin: left; the label cuts in when the fill is halfway and the sentence slides in right behind the fill (the fill covers so much ground that the accumulated area lands at 21.95%, the largest of the nine). In real use, resting a hand on the box deepens the label color one beat later via transition-delay.

scaleXtransform-origintransition-delay
// The filling background — sits a layer below the content (z-index) and scales from the left
.qt::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  background: $color;          // lilac
  opacity: .3;
}
.qt.is-demo::before {
  transform-origin: left center;
  animation-name: qtFill;
  animation-duration: $duration;
  animation-timing-function: $ease-out;
  animation-iteration-count: infinite;
}
@keyframes qtFill {
  0%        { transform: scaleX(0); }
  18%, 74%  { transform: scaleX(1); }
  96%, 100% { transform: scaleX(0); }   // slower than the entrance — the exit must not win the poster
}

05Quote framed by marks sliding inward

For interviews and research reports. Two oversized serif glyphs set with ::before and ::after slide in from beyond the edges and tighten around the sentence, which lifts once at mid-loop (accumulated area 4.822%). The marks are typography, not icons, so font-family: Georgia gives them their display-serif shape, and letting them hang well above and below the text is the whole point of this box.

translateXopacity::before
// The end marks — absolutely positioned glyphs tightening inward, kept opaque (no fade)
.qs__body::before,
.qs__body::after {
  position: absolute;
  z-index: -1;
  font-family: Georgia, "Times New Roman", serif;
  font-size: 52px;
  color: $subject-lilac;
  opacity: .9;
}
.qs__body::before { content: "\201C"; top: -28px; left: -20px; }
.qs__body::after  { content: "\201D"; bottom: -28px; right: -20px; }
@keyframes qsMarkBefore {
  0%        { opacity: .9; transform: translateX(-30px) scale(.7); }
  28%, 68%  { opacity: .9; transform: translateX(0) scale(1); }
  92%, 100% { opacity: .9; transform: translateX(-30px) scale(.7); }
}
// The closing mark (qsMarkAfter) mirrors the direction at +30px — both glyphs move in the same frames

06Source line revealing the speaker a beat late

For editorial interviews and customer blurbs. As the quote settles, an initial disc pops with scale, the name and role rise after it, and an underline fills left to right with scaleX (accumulated area 3.433%, intensity 111.5). Three of the four elements — quote, disc, and name — enter inside the same 6–24% window so several move in one frame, while the underline waits until 28%; why that placement matters is the first story in the trap section.

translateYscaleXcolor
// The source row — disc (left) and name·role (right) joined horizontally, underline at the bottom edge
.qc__src {
  display: flex;
  align-items: center;
  gap: $sp-3;
  padding-bottom: $sp-2;
  position: relative;
}
.qc__src::after {           // underline — fills from a left origin
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 2px;
  border-radius: $r-pill;
  background: rgba(23, 20, 26, .8);
  transform-origin: left center;
}
@keyframes qcDisc {          // initial disc — pops overlapping the quote (overshoot)
  0%, 6%    { opacity: 0; transform: scale(.3); }
  18%       { opacity: 1; transform: scale(1.12); }
  24%, 62%  { opacity: 1; transform: scale(1); }
  76%, 100% { opacity: 0; }
}

07Quote as an ink block flipping in

When you want weight — portfolio statements, product philosophy. An ink block wipes in from the left with clip-path inset, and the white sentence flips on top of it in a single steps(1, end) cut — mixing steps timing and clip-path in one @keyframes silently kills interpolation, so the block (wipe) and the sentence (cut) are two separate animations. White on ink measures 18.24:1; the area is 20.317% and the intensity 203.3 is the highest of the nine.

clip-pathsteps(1, end)color
// The ink wipe and the text cut are separate animations — steps and clip-path don't interpolate together
.qi.is-demo {
  animation-name: qiWipe;                        // eased wipe
  animation-timing-function: $ease-out;
}
.qi.is-demo .qi__l1,
.qi.is-demo .qi__l2 {
  animation-name: qiCut1;
  animation-timing-function: steps(1, end);      // single-frame cut
  animation-iteration-count: infinite;
}
@keyframes qiWipe {
  0%        { clip-path: inset(0 100% 0 0 round 16px); }
  16%, 74%  { clip-path: inset(0 0 0 0 round 16px); }
  92%, 100% { clip-path: inset(0 100% 0 0 round 16px); }
}
@keyframes qiCut1 {
  0%, 17%   { opacity: 0; }
  21%, 70%  { opacity: 1; }
  74%, 100% { opacity: 0; }
}

08Quote with a giant mark ghosting behind

For essay openings and book-cover lines. A 120px quotation glyph rises slowly behind the text at 22% lilac to become the background, while the foreground sentence stays crisp one layer up with z-index (accumulated area 4.192%). The design move is cutting the sentence in early, at 10%, so it overlaps the watermark's slow rise, which takes until 46% of the loop to finish.

opacityscalez-index
// The giant mark — rises a layer below the text (z-index: 0)
.qw__mark {
  position: absolute;
  z-index: 0;
  font-family: Georgia, "Times New Roman", serif;
  font-size: 120px;
  color: $subject-lilac;
  opacity: .22;
}
// The sentence cuts in with steps, then rises — timing functions inside keyframes split per segment
@keyframes qwBody {
  0%        { opacity: 0; transform: translateY(10px); animation-timing-function: steps(1, end); }
  10%       { opacity: 1; transform: translateY(10px); animation-timing-function: $easing; }
  30%, 60%  { opacity: 1; transform: translateY(0); }
  74%, 100% { opacity: 0; }
}

09Quote that swaps lines on hover

The rotating line under a landing hero. Three quotes are stacked in one cell with grid-area: 1 / 1 and only one shows at a time; in the grid preview a steps(1, end) cut swaps the sentence at exactly 33% and 67% (its 1.697% area is the quietest of the nine, but an intensity of 96.7 keeps the swap crisp). Resting a hand on the card makes the script drop the preview loop and advance to the next line, while the button stays as the keyboard path.

steps(1, end)translateYgrid-area
// Three sentences stacked in one cell — the longest one decides the height
.qx__swap { display: grid; min-height: 48px; }
.qx__line {
  grid-area: 1 / 1;
  opacity: 0;
  transition-property: opacity;
  transition-duration: 140ms;      // real swaps get a short opacity cross
}
.qx__line.is-on { opacity: 1; }
// The grid preview loop swaps by cut — both sentences change at exactly 33% (no overlap, no blank)
@keyframes qxLine1 {
  0%        { opacity: 1; }
  33%, 100% { opacity: 0; }
}
@keyframes qxLine2 {
  0%        { opacity: 0; }
  33%       { opacity: 1; }
  67%, 100% { opacity: 0; }
}

Where it breaks — the trap

The first thing that broke was the poster turning into an empty stage. The first version of 06 revealed its four elements one at a time, and the frame where the whole body text vanished in a single cut beat every entrance frame — the poster came out at 0.1% changed area, an empty yellow stage. Two fixes went in: the entrances now overlap between 6% and 24% so several elements move in the same frame, and each element leaves on its own gradual fade of 12% or more. Re-measured, 06 came back at 3.433% area with 18 of 23 frames moving — 24 frames are captured, which leaves 23 gaps to compare.

The second was the semi-transparent ghost of fading text. The big lines of 03 first rose on an opacity ramp, and the frame where the letters were half transparent got picked as the poster — measuring the pixels put the blend rate at about 0.5. Text now avoids ramps entirely: 03 settles by translateY alone, fully opaque; 08 cuts in with steps(1, end) and then moves; the context lines are cuts too. After the fix the big lines of 03 come back at a blend rate of 0.87 or higher.

The third was a double advance in 09. Hovering the card advanced the quote, and the button did the same — but a mouse click enters the card first, so a single click skipped two lines. The button now only accepts keyboard activation, where event.detail is 0; the mouse path belongs to hover and the keyboard path to the button. Nailing the preview cuts at exactly 33% and 67% was the same lesson — if one line leaves before the next arrives, you get a one-frame blank that reads as a flicker.

The last one was outside the stage, not on it. The giant glyphs of 05 hang off the sentence with position: absolute, and a bounding box grown by rotation or scale still counts toward scrollHeight even under overflow: hidden. Their offsets stay within 28px above and below, so on a 320px phone — where the stage has 174px of vertical room — the measured overflow is zero. All nine sources ship in the zip exactly as measured for this article, and the password to open it is zqpvs5yk.

Accessibility (reduced-motion)

Under prefers-reduced-motion: reduce all nine follow one rule: the self-running entrance stops, and the quote itself stays in its finished state. The static rules come first and the .is-demo loop is layered on top, so with animation off the bar of 01 stays filled, the box of 04 stays tinted, and the watermark of 08 stays risen. Every declaration in the reduce block carries !important, because the loop selectors stack classes and would otherwise win on specificity. 09 pins the first line and keeps the button path alive. The reference is MDN's prefers-reduced-motion page.

Text contrast was measured from the actual pixel values.

Spot Foreground / background Measured Standard
01 card text ink / cream 17.11:1 passes AA and AAA
02·06 stage text ink / yellow 12.63:1 passes AA and AAA
03 big line (22px 800) white / orange 3.32:1 passes AA for large text
07 block text white / ink 18.24:1 passes AA and AAA
04·08 stage text ink / white 18.24:1 passes AA and AAA
03 context line (11px) cream 88% / orange 2.7:1 not for copy — stage dressing

The context line of 03 is dressing for the demo stage, not something you would ship; the real component is the big line, which is 22px bold and clears the AA large-text bar of 3:1. The meaning lives in the HTML — wrap quotes in <blockquote>, put aria-live="polite" on a line that changes as 09 does, and aria-hidden on the stacked invisible lines. The standard for the quote element itself is on MDN's blockquote page.

More ways to move letters live in CSS Text Animation 9 Ways, and the sections that display other people's praise are in Testimonial & Social Proof Sections. How this site renders and measures demos before writing about them is on the about page.

FAQ

Can I add the quotation marks with a CSS ::before?

Yes. Injecting them with content: "\201C" as in 05 keeps the HTML clean, and since screen readers treat CSS-generated characters inconsistently, a purely decorative mark is better left out of the text. If the marks carry meaning, put them in the HTML instead.

Is a pull quote a different tag from a blockquote?

No. The HTML tag is <blockquote> either way; "pull quote" is the name of the use — quoting someone else (01, 02, 04) versus lifting one of your own lines and enlarging it (03, 07, 08). What changes between them is the size and the placement, not the element you reach for.

How do I recolor these for my blog?

Change the three variables at the top of each style.scss$duration, $easing, $color. $color drives the bar in 01, the fill in 04, and the ink block in 07; the React port takes the same slots as props. You never have to touch the stage colors — only the part recolors.

Enter the archive password

The password is inside this article. You will find it as you read.